diff --git a/.swiftformat b/.swiftformat index 7b2a75eb..6b951e8f 100644 --- a/.swiftformat +++ b/.swiftformat @@ -43,6 +43,6 @@ redundantClosure, \ redundantType ---exclude Shared/Generated/Strings.swift +--exclude Shared/Strings/Strings.swift --header "\nSwiftfin is subject to the terms of the Mozilla Public\nLicense, v2.0. If a copy of the MPL was not distributed with this\nfile, you can obtain one at https://mozilla.org/MPL/2.0/.\n\nCopyright (c) {year} Jellyfin & Jellyfin Contributors\n" diff --git a/Shared/BlurHashKit/BlurHash.swift b/Shared/BlurHashKit/BlurHash.swift new file mode 100755 index 00000000..e5a03f98 --- /dev/null +++ b/Shared/BlurHashKit/BlurHash.swift @@ -0,0 +1,63 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation + +public struct BlurHash { + public let components: [[(Float, Float, Float)]] + + public var numberOfHorizontalComponents: Int { components.first!.count } + public var numberOfVerticalComponents: Int { components.count } + + public init(components: [[(Float, Float, Float)]]) { + self.components = components + } + + public func punch(_ factor: Float) -> BlurHash { + BlurHash(components: components.enumerated().map { j, horizontalComponents -> [(Float, Float, Float)] in + horizontalComponents.enumerated().map { i, component -> (Float, Float, Float) in + if i == 0 && j == 0 { + return component + } else { + return component * factor + } + } + }) + } +} + +public func + (lhs: BlurHash, rhs: BlurHash) throws -> BlurHash { + BlurHash(components: paddedZip(lhs.components, rhs.components, [], []).map { + paddedZip($0.0, $0.1, (0, 0, 0) as (Float, Float, Float), (0, 0, 0) as (Float, Float, Float)) + .map { ($0.0.0 + $0.1.0, $0.0.1 + $0.1.1, $0.0.2 + $0.1.2) } + }) +} + +public func - (lhs: BlurHash, rhs: BlurHash) throws -> BlurHash { + BlurHash(components: paddedZip(lhs.components, rhs.components, [], []).map { + paddedZip($0.0, $0.1, (0, 0, 0) as (Float, Float, Float), (0, 0, 0) as (Float, Float, Float)) + .map { ($0.0.0 - $0.1.0, $0.0.1 - $0.1.1, $0.0.2 - $0.1.2) } + }) +} + +private func paddedZip( + _ collection1: Collection1, + _ collection2: Collection2, + _ padding1: Collection1.Element, + _ padding2: Collection2.Element +) -> Zip2Sequence<[Collection1.Element], [Collection2.Element]> where Collection1: Collection, Collection2: Collection { + if collection1.count < collection2.count { + let padded = collection1 + Array(repeating: padding1, count: collection2.count - collection1.count) + return zip(padded, Array(collection2)) + } else if collection2.count < collection1.count { + let padded = collection2 + Array(repeating: padding2, count: collection1.count - collection2.count) + return zip(Array(collection1), padded) + } else { + return zip(Array(collection1), Array(collection2)) + } +} diff --git a/Shared/BlurHashKit/ColourProbes.swift b/Shared/BlurHashKit/ColourProbes.swift new file mode 100755 index 00000000..26817493 --- /dev/null +++ b/Shared/BlurHashKit/ColourProbes.swift @@ -0,0 +1,102 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation + +public extension BlurHash { + func linearRGB(atX x: Float) -> (Float, Float, Float) { + return components[0].enumerated().reduce((0, 0, 0)) { sum, horizontalEnumerated -> (Float, Float, Float) in + let (i, component) = horizontalEnumerated + return sum + component * cos(Float.pi * Float(i) * x) + } + } + + func linearRGB(atY y: Float) -> (Float, Float, Float) { + return components.enumerated().reduce((0, 0, 0)) { sum, verticalEnumerated in + let (j, horizontalComponents) = verticalEnumerated + return sum + horizontalComponents[0] * cos(Float.pi * Float(j) * y) + } + } + + func linearRGB(at position: (Float, Float)) -> (Float, Float, Float) { + return components.enumerated().reduce((0, 0, 0)) { sum, verticalEnumerated in + let (j, horizontalComponents) = verticalEnumerated + return horizontalComponents.enumerated().reduce(sum) { sum, horizontalEnumerated in + let (i, component) = horizontalEnumerated + return sum + component * cos(Float.pi * Float(i) * position.0) * cos(Float.pi * Float(j) * position.1) + } + } + } + + func linearRGB(from upperLeft: (Float, Float), to lowerRight: (Float, Float)) -> (Float, Float, Float) { + return components.enumerated().reduce((0, 0, 0)) { sum, verticalEnumerated in + let (j, horizontalComponents) = verticalEnumerated + return horizontalComponents.enumerated().reduce(sum) { sum, horizontalEnumerated in + let (i, component) = horizontalEnumerated + let horizontalAverage: Float = i == 0 ? 1 : + (sin(Float.pi * Float(i) * lowerRight.0) - sin(Float.pi * Float(i) * upperLeft.0)) / + (Float(i) * Float.pi * (lowerRight.0 - upperLeft.0)) + let veritcalAverage: Float = j == 0 ? 1 : + (sin(Float.pi * Float(j) * lowerRight.1) - sin(Float.pi * Float(j) * upperLeft.1)) / + (Float(j) * Float.pi * (lowerRight.1 - upperLeft.1)) + return sum + component * horizontalAverage * veritcalAverage + } + } + } + + func linearRGB(at upperLeft: (Float, Float), size: (Float, Float)) -> (Float, Float, Float) { + return linearRGB(from: upperLeft, to: (upperLeft.0 + size.0, upperLeft.1 + size.1)) + } + + var averageLinearRGB: (Float, Float, Float) { + return components[0][0] + } + + var leftEdgeLinearRGB: (Float, Float, Float) { return linearRGB(atX: 0) } + var rightEdgeLinearRGB: (Float, Float, Float) { return linearRGB(atX: 1) } + var topEdgeLinearRGB: (Float, Float, Float) { return linearRGB(atY: 0) } + var bottomEdgeLinearRGB: (Float, Float, Float) { return linearRGB(atY: 1) } + var topLeftCornerLinearRGB: (Float, Float, Float) { return linearRGB(at: (0, 0)) } + var topRightCornerLinearRGB: (Float, Float, Float) { return linearRGB(at: (1, 0)) } + var bottomLeftCornerLinearRGB: (Float, Float, Float) { return linearRGB(at: (0, 1)) } + var bottomRightCornerLinearRGB: (Float, Float, Float) { return linearRGB(at: (1, 1)) } +} + +public extension BlurHash { + func isDark(linearRGB rgb: (Float, Float, Float), threshold: Float = 0.3) -> Bool { + rgb.0 * 0.299 + rgb.1 * 0.587 + rgb.2 * 0.114 < threshold + } + + func isDark(threshold: Float = 0.3) -> Bool { isDark(linearRGB: averageLinearRGB, threshold: threshold) } + + func isDark(atX x: Float, threshold: Float = 0.3) -> Bool { isDark(linearRGB: linearRGB(atX: x), threshold: threshold) } + func isDark(atY y: Float, threshold: Float = 0.3) -> Bool { isDark(linearRGB: linearRGB(atY: y), threshold: threshold) } + func isDark( + at position: (Float, Float), + threshold: Float = 0.3 + ) -> Bool { isDark(linearRGB: linearRGB(at: position), threshold: threshold) } + func isDark( + from upperLeft: (Float, Float), + to lowerRight: (Float, Float), + threshold: Float = 0.3 + ) -> Bool { isDark(linearRGB: linearRGB(from: upperLeft, to: lowerRight), threshold: threshold) } + func isDark( + at upperLeft: (Float, Float), + size: (Float, Float), + threshold: Float = 0.3 + ) -> Bool { isDark(linearRGB: linearRGB(at: upperLeft, size: size), threshold: threshold) } + + var isLeftEdgeDark: Bool { isDark(atX: 0) } + var isRightEdgeDark: Bool { isDark(atX: 1) } + var isTopEdgeDark: Bool { isDark(atY: 0) } + var isBottomEdgeDark: Bool { isDark(atY: 1) } + var isTopLeftCornerDark: Bool { isDark(at: (0, 0)) } + var isTopRightCornerDark: Bool { isDark(at: (1, 0)) } + var isBottomLeftCornerDark: Bool { isDark(at: (0, 1)) } + var isBottomRightCornerDark: Bool { isDark(at: (1, 1)) } +} diff --git a/Shared/BlurHashKit/ColourSpace.swift b/Shared/BlurHashKit/ColourSpace.swift new file mode 100755 index 00000000..dc2f464b --- /dev/null +++ b/Shared/BlurHashKit/ColourSpace.swift @@ -0,0 +1,25 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation + +func signPow(_ value: Float, _ exp: Float) -> Float { + copysign(pow(abs(value), exp), value) +} + +func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +func sRGBToLinear(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} diff --git a/Shared/BlurHashKit/EscapeSequences.swift b/Shared/BlurHashKit/EscapeSequences.swift new file mode 100755 index 00000000..8185a922 --- /dev/null +++ b/Shared/BlurHashKit/EscapeSequences.swift @@ -0,0 +1,43 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation + +extension BlurHash { + var twoByThreeEscapeSequence: String { + let areas: [(from: (Float, Float), to: (Float, Float))] = [ + (from: (0, 0), to: (0.333, 0.5)), + (from: (0, 0.5), to: (0.333, 1.0)), + (from: (0.333, 0), to: (0.666, 0.5)), + (from: (0.333, 0.5), to: (0.666, 1.0)), + (from: (0.666, 0), to: (1.0, 0.5)), + (from: (0.666, 0.5), to: (1.0, 1.0)), + ] + + let rgb: [(Float, Float, Float)] = areas.map { area in + linearRGB(from: area.from, to: area.to) + } + + let maxRgb: (Float, Float, Float) = rgb.reduce((-Float.infinity, -Float.infinity, -Float.infinity), max) + let minRgb: (Float, Float, Float) = rgb.reduce((Float.infinity, Float.infinity, Float.infinity), min) + + let positiveScale: (Float, Float, Float) = ((1, 1, 1) - averageLinearRGB) / (maxRgb - averageLinearRGB) + let negativeScale: (Float, Float, Float) = averageLinearRGB / (averageLinearRGB - minRgb) + let scale: (Float, Float, Float) = min(positiveScale, negativeScale) + + let scaledRgb: [(Float, Float, Float)] = rgb.map { rgb in + (rgb - averageLinearRGB) * scale + averageLinearRGB + } + + let c = scaledRgb.map { rgb in + (linearTosRGB(rgb.0) / 51) * 36 + (linearTosRGB(rgb.1) / 51) * 6 + (linearTosRGB(rgb.2) / 51) + 16 + } + + return "\u{1b}[38;5;\(c[1]);48;5;\(c[0])m▄\u{1b}[38;5;\(c[3]);48;5;\(c[2])m▄\u{1b}[38;5;\(c[5]);48;5;\(c[4])m▄\u{1b}[m" + } +} diff --git a/Shared/BlurHashKit/FromString.swift b/Shared/BlurHashKit/FromString.swift new file mode 100755 index 00000000..756a793c --- /dev/null +++ b/Shared/BlurHashKit/FromString.swift @@ -0,0 +1,76 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation + +public extension BlurHash { + init?(string: String) { + guard string.count >= 6 else { return nil } + + let sizeFlag = String(string[0]).decode83() + let numberOfHorizontalComponents = (sizeFlag % 9) + 1 + let numberOfVerticalComponents = (sizeFlag / 9) + 1 + + let quantisedMaximumValue = String(string[1]).decode83() + let maximumValue = Float(quantisedMaximumValue + 1) / 166 + + guard string.count == 4 + 2 * numberOfHorizontalComponents * numberOfVerticalComponents else { return nil } + + self.components = (0 ..< numberOfVerticalComponents).map { j in + (0 ..< numberOfHorizontalComponents).map { i in + if i == 0 && j == 0 { + let value = String(string[2 ..< 6]).decode83() + return BlurHash.decodeDC(value) + } else { + let index = i + j * numberOfHorizontalComponents + let value = String(string[4 + index * 2 ..< 4 + index * 2 + 2]).decode83() + return BlurHash.decodeAC(value, maximumValue: maximumValue) + } + } + } + } + + private static func decodeDC(_ value: Int) -> (Float, Float, Float) { + let intR = value >> 16 + let intG = (value >> 8) & 255 + let intB = value & 255 + return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) + } + + private static func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { + let quantR = value / (19 * 19) + let quantG = (value / 19) % 19 + let quantB = value % 19 + + let rgb = ( + signPow((Float(quantR) - 9) / 9, 2) * maximumValue, + signPow((Float(quantG) - 9) / 9, 2) * maximumValue, + signPow((Float(quantB) - 9) / 9, 2) * maximumValue + ) + + return rgb + } +} + +private extension String { + subscript(offset: Int) -> Character { + self[index(startIndex, offsetBy: offset)] + } + + subscript(bounds: CountableClosedRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start ... end] + } + + subscript(bounds: CountableRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start ..< end] + } +} diff --git a/Shared/BlurHashKit/FromUIImage.swift b/Shared/BlurHashKit/FromUIImage.swift new file mode 100755 index 00000000..799efa4d --- /dev/null +++ b/Shared/BlurHashKit/FromUIImage.swift @@ -0,0 +1,92 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import UIKit + +public extension BlurHash { + init?(image: UIImage, numberOfComponents components: (Int, Int)) { + guard components.0 >= 1, components.0 <= 9, + components.1 >= 1, components.1 <= 9 + else { + fatalError("Number of components bust be between 1 and 9 inclusive on each axis") + } + + let pixelWidth = Int(round(image.size.width * image.scale)) + let pixelHeight = Int(round(image.size.height * image.scale)) + + let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: pixelWidth * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.scaleBy(x: image.scale, y: -image.scale) + context.translateBy(x: 0, y: -image.size.height) + + UIGraphicsPushContext(context) + image.draw(at: .zero) + UIGraphicsPopContext() + + guard let cgImage = context.makeImage(), + let dataProvider = cgImage.dataProvider, + let data = dataProvider.data, + let pixels = CFDataGetBytePtr(data) + else { + assertionFailure("Unexpected error!") + return nil + } + + let width = cgImage.width + let height = cgImage.height + let bytesPerRow = cgImage.bytesPerRow + + self.components = (0 ..< components.1).map { j -> [(Float, Float, Float)] in + (0 ..< components.0).map { i -> (Float, Float, Float) in + let normalisation: Float = (i == 0 && j == 0) ? 1 : 2 + return BlurHash.multiplyBasisFunction( + pixels: pixels, + width: width, + height: height, + bytesPerRow: bytesPerRow, + bytesPerPixel: cgImage.bitsPerPixel / 8 + ) { x, y in + normalisation * cos(Float.pi * Float(i) * x / Float(width)) as Float * + cos(Float.pi * Float(j) * y / Float(height)) as Float + } + } + } + } + + private static func multiplyBasisFunction( + pixels: UnsafePointer, + width: Int, + height: Int, + bytesPerRow: Int, + bytesPerPixel: Int, + basisFunction: (Float, Float) -> Float + ) -> (Float, Float, Float) { + var c: (Float, Float, Float) = (0, 0, 0) + + let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow) + + for x in 0 ..< width { + for y in 0 ..< height { + c += basisFunction(Float(x), Float(y)) * ( + sRGBToLinear(buffer[bytesPerPixel * x + 0 + y * bytesPerRow]), + sRGBToLinear(buffer[bytesPerPixel * x + 1 + y * bytesPerRow]), + sRGBToLinear(buffer[bytesPerPixel * x + 2 + y * bytesPerRow]) + ) + } + } + + return c / Float(width * height) + } +} diff --git a/Shared/BlurHashKit/Generation.swift b/Shared/BlurHashKit/Generation.swift new file mode 100755 index 00000000..92b27cd8 --- /dev/null +++ b/Shared/BlurHashKit/Generation.swift @@ -0,0 +1,115 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import UIKit + +public extension BlurHash { + init(blendingTop top: BlurHash, bottom: BlurHash) { + guard top.components.count == 1, bottom.components.count == 1 else { + fatalError("Blended BlurHashses must have only one vertical component") + } + + let average = zip(top.components[0], bottom.components[0]).map { ($0 + $1) / 2 } + let difference = zip(top.components[0], bottom.components[0]).map { ($0 - $1) / 2 } + self.init(components: [average, difference]) + } + + init(blendingLeft left: BlurHash, right: BlurHash) { + self = BlurHash(blendingTop: left.transposed, bottom: right.transposed).transposed + } +} + +public extension BlurHash { + init(colour: UIColor) { + self.init(components: [[colour.linear]]) + } + + init(blendingTop topColour: UIColor, bottom bottomColour: UIColor) { + self = BlurHash(blendingTop: .init(colour: topColour), bottom: .init(colour: bottomColour)) + } + + init(blendingLeft leftColour: UIColor, right rightColour: UIColor) { + self = BlurHash(blendingLeft: .init(colour: leftColour), right: .init(colour: rightColour)) + } + + init( + blendingTopLeft topLeftColour: UIColor, + topRight topRightColour: UIColor, + bottomLeft bottomLeftColour: UIColor, + bottomRight bottomRightColour: UIColor + ) { + self = BlurHash( + blendingTop: BlurHash(blendingTop: topLeftColour, bottom: topRightColour).transposed, + bottom: BlurHash(blendingTop: bottomLeftColour, bottom: bottomRightColour).transposed + ) + } +} + +public extension BlurHash { + init(horizontalColours colours: [(Float, Float, Float)], numberOfComponents: Int) { + guard numberOfComponents >= 1, numberOfComponents <= 9 else { + fatalError("Number of components bust be between 1 and 9 inclusive") + } + + self.init(components: [(0 ..< numberOfComponents).map { i in + let normalisation: Float = i == 0 ? 1 : 2 + var sum: (Float, Float, Float) = (0, 0, 0) + for x in 0 ..< colours.count { + let basis = normalisation * cos(Float.pi * Float(i) * Float(x) / Float(colours.count - 1)) + sum += basis * colours[x] + } + + return sum / Float(colours.count) + }]) + } +} + +public extension BlurHash { + var mirroredHorizontally: BlurHash { + .init(components: (0 ..< numberOfVerticalComponents).map { j -> [(Float, Float, Float)] in + (0 ..< numberOfHorizontalComponents).map { i -> (Float, Float, Float) in + components[j][i] * (i % 2 == 0 ? 1 : -1) + } + }) + } + + var mirroredVertically: BlurHash { + .init(components: (0 ..< numberOfVerticalComponents).map { j -> [(Float, Float, Float)] in + (0 ..< numberOfHorizontalComponents).map { i -> (Float, Float, Float) in + components[j][i] * (j % 2 == 0 ? 1 : -1) + } + }) + } + + var transposed: BlurHash { + .init(components: (0 ..< numberOfHorizontalComponents).map { i in + (0 ..< numberOfVerticalComponents).map { j in + components[j][i] + } + }) + } +} + +extension UIColor { + var linear: (Float, Float, Float) { + guard let c = cgColor.converted(to: CGColorSpace(name: CGColorSpace.sRGB)!, intent: .defaultIntent, options: nil)?.components + else { return (0, 0, 0) } + + switch c.count { + case 1, 2: return (sRGBToLinear(c[0]), sRGBToLinear(c[0]), sRGBToLinear(c[0])) + case 3, 4: return (sRGBToLinear(c[0]), sRGBToLinear(c[1]), sRGBToLinear(c[2])) + default: return (0, 0, 0) + } + } +} + +func sRGBToLinear(_ value: CGFloat) -> Float { + let v = Float(value) + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} diff --git a/Shared/BlurHashKit/StringCoding.swift b/Shared/BlurHashKit/StringCoding.swift new file mode 100755 index 00000000..857a7295 --- /dev/null +++ b/Shared/BlurHashKit/StringCoding.swift @@ -0,0 +1,48 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation + +private let encodeCharacters: [String] = { + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +private let decodeCharacters: [String: Int] = { + var dict: [String: Int] = [:] + for (index, character) in encodeCharacters.enumerated() { + dict[character] = index + } + return dict +}() + +extension BinaryInteger { + func encode83(length: Int) -> String { + var result = "" + for i in 1 ... length { + let digit = (Int(self) / pow(83, length - i)) % 83 + result += encodeCharacters[Int(digit)] + } + return result + } +} + +extension String { + func decode83() -> Int { + var value: Int = 0 + for character in self { + if let digit = decodeCharacters[String(character)] { + value = value * 83 + digit + } + } + return value + } +} + +private func pow(_ base: Int, _ exponent: Int) -> Int { + (0 ..< exponent).reduce(1) { value, _ in value * base } +} diff --git a/Shared/BlurHashKit/ToString.swift b/Shared/BlurHashKit/ToString.swift new file mode 100755 index 00000000..8c8adfb1 --- /dev/null +++ b/Shared/BlurHashKit/ToString.swift @@ -0,0 +1,56 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation + +public extension BlurHash { + var string: String { + let flatComponents = components.reduce([]) { $0 + $1 } + let dc = flatComponents.first! + let ac = flatComponents.dropFirst() + + var hash = "" + + let sizeFlag = (numberOfHorizontalComponents - 1) + (numberOfVerticalComponents - 1) * 9 + hash += sizeFlag.encode83(length: 1) + + let maximumValue: Float + if !ac.isEmpty { + let actualMaximumValue = ac.map { max(abs($0.0), abs($0.1), abs($0.2)) }.max()! + let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5)))) + maximumValue = Float(quantisedMaximumValue + 1) / 166 + hash += quantisedMaximumValue.encode83(length: 1) + } else { + maximumValue = 1 + hash += 0.encode83(length: 1) + } + + hash += encodeDC(dc).encode83(length: 4) + + for factor in ac { + hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2) + } + + return hash + } + + private func encodeDC(_ value: (Float, Float, Float)) -> Int { + let roundedR = linearTosRGB(value.0) + let roundedG = linearTosRGB(value.1) + let roundedB = linearTosRGB(value.2) + return (roundedR << 16) + (roundedG << 8) + roundedB + } + + private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int { + let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5)))) + let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5)))) + let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5)))) + + return quantR * 19 * 19 + quantG * 19 + quantB + } +} diff --git a/Shared/BlurHashKit/ToUIImage.swift b/Shared/BlurHashKit/ToUIImage.swift new file mode 100755 index 00000000..79bbf51b --- /dev/null +++ b/Shared/BlurHashKit/ToUIImage.swift @@ -0,0 +1,101 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import UIKit + +public extension BlurHash { + func cgImage(size: CGSize) -> CGImage? { + let width = Int(size.width) + let height = Int(size.height) + let bytesPerRow = width * 3 + + guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } + CFDataSetLength(data, bytesPerRow * height) + + guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } + + for y in 0 ..< height { + for x in 0 ..< width { + var c: (Float, Float, Float) = (0, 0, 0) + + for j in 0 ..< numberOfVerticalComponents { + for i in 0 ..< numberOfHorizontalComponents { + let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) + let component = components[j][i] + c += component * basis + } + } + + let intR = UInt8(linearTosRGB(c.0)) + let intG = UInt8(linearTosRGB(c.1)) + let intB = UInt8(linearTosRGB(c.2)) + + pixels[3 * x + 0 + y * bytesPerRow] = intR + pixels[3 * x + 1 + y * bytesPerRow] = intG + pixels[3 * x + 2 + y * bytesPerRow] = intB + } + } + + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + + guard let provider = CGDataProvider(data: data) else { return nil } + guard let cgImage = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 24, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: bitmapInfo, + provider: provider, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) else { return nil } + + return cgImage + } + + func cgImage(numberOfPixels: Int = 1024, originalSize size: CGSize) -> CGImage? { + let width: CGFloat + let height: CGFloat + if size.width > size.height { + width = floor(sqrt(CGFloat(numberOfPixels) * size.width / size.height) + 0.5) + height = floor(CGFloat(numberOfPixels) / width + 0.5) + } else { + height = floor(sqrt(CGFloat(numberOfPixels) * size.height / size.width) + 0.5) + width = floor(CGFloat(numberOfPixels) / height + 0.5) + } + return cgImage(size: CGSize(width: width, height: height)) + } + + func image(size: CGSize) -> UIImage? { + guard let cgImage = cgImage(size: size) else { return nil } + return UIImage(cgImage: cgImage) + } + + func image(numberOfPixels: Int = 1024, originalSize size: CGSize) -> UIImage? { + guard let cgImage = cgImage(numberOfPixels: numberOfPixels, originalSize: size) else { return nil } + return UIImage(cgImage: cgImage) + } +} + +@objc +public extension UIImage { + convenience init?(blurHash string: String, size: CGSize, punch: Float = 1) { + guard let blurHash = BlurHash(string: string), + let cgImage = blurHash.punch(punch).cgImage(size: size) else { return nil } + self.init(cgImage: cgImage) + } + + convenience init?(blurHash string: String, numberOfPixels: Int = 1024, originalSize size: CGSize, punch: Float = 1) { + guard let blurHash = BlurHash(string: string), + let cgImage = blurHash.punch(punch).cgImage(numberOfPixels: numberOfPixels, originalSize: size) else { return nil } + self.init(cgImage: cgImage) + } +} diff --git a/Shared/BlurHashKit/TupleMaths.swift b/Shared/BlurHashKit/TupleMaths.swift new file mode 100755 index 00000000..9cbfb18d --- /dev/null +++ b/Shared/BlurHashKit/TupleMaths.swift @@ -0,0 +1,61 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation + +func + (lhs: (Float, Float, Float), rhs: (Float, Float, Float)) -> (Float, Float, Float) { + return (lhs.0 + rhs.0, lhs.1 + rhs.1, lhs.2 + rhs.2) +} + +func - (lhs: (Float, Float, Float), rhs: (Float, Float, Float)) -> (Float, Float, Float) { + return (lhs.0 - rhs.0, lhs.1 - rhs.1, lhs.2 - rhs.2) +} + +func * (lhs: (Float, Float, Float), rhs: (Float, Float, Float)) -> (Float, Float, Float) { + return (lhs.0 * rhs.0, lhs.1 * rhs.1, lhs.2 * rhs.2) +} + +func * (lhs: (Float, Float, Float), rhs: Float) -> (Float, Float, Float) { + return (lhs.0 * rhs, lhs.1 * rhs, lhs.2 * rhs) +} + +func * (lhs: Float, rhs: (Float, Float, Float)) -> (Float, Float, Float) { + return (lhs * rhs.0, lhs * rhs.1, lhs * rhs.2) +} + +func / (lhs: (Float, Float, Float), rhs: (Float, Float, Float)) -> (Float, Float, Float) { + return (lhs.0 / rhs.0, lhs.1 / rhs.1, lhs.2 / rhs.2) +} + +func / (lhs: (Float, Float, Float), rhs: Float) -> (Float, Float, Float) { + return (lhs.0 / rhs, lhs.1 / rhs, lhs.2 / rhs) +} + +func += (lhs: inout (Float, Float, Float), rhs: (Float, Float, Float)) { + lhs = lhs + rhs +} + +func -= (lhs: inout (Float, Float, Float), rhs: (Float, Float, Float)) { + lhs = lhs - rhs +} + +func *= (lhs: inout (Float, Float, Float), rhs: Float) { + lhs = lhs * rhs +} + +func /= (lhs: inout (Float, Float, Float), rhs: Float) { + lhs = lhs / rhs +} + +func min(_ a: (Float, Float, Float), _ b: (Float, Float, Float)) -> (Float, Float, Float) { + return (min(a.0, b.0), min(a.1, b.1), min(a.2, b.2)) +} + +func max(_ a: (Float, Float, Float), _ b: (Float, Float, Float)) -> (Float, Float, Float) { + return (max(a.0, b.0), max(a.1, b.1), max(a.2, b.2)) +} diff --git a/Shared/Coordinators/BasicAppSettingsCoordinator.swift b/Shared/Coordinators/BasicAppSettingsCoordinator.swift index 1033a87d..d08c0222 100644 --- a/Shared/Coordinators/BasicAppSettingsCoordinator.swift +++ b/Shared/Coordinators/BasicAppSettingsCoordinator.swift @@ -21,7 +21,7 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable { @ViewBuilder func makeAbout() -> some View { - AboutView() + AboutAppView() } @ViewBuilder diff --git a/Shared/Coordinators/HomeCoordinator.swift b/Shared/Coordinators/HomeCoordinator.swift index e4501fcf..0e77d30e 100644 --- a/Shared/Coordinators/HomeCoordinator.swift +++ b/Shared/Coordinators/HomeCoordinator.swift @@ -19,14 +19,18 @@ final class HomeCoordinator: NavigationCoordinatable { var start = makeStart @Route(.modal) var settings = makeSettings - @Route(.push) - var library = makeLibrary - @Route(.push) - var item = makeItem - @Route(.modal) - var modalItem = makeModalItem - @Route(.modal) - var modalLibrary = makeModalLibrary + + #if os(tvOS) + @Route(.modal) + var item = makeModalItem + @Route(.modal) + var library = makeModalLibrary + #else + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + #endif func makeSettings() -> NavigationViewCoordinator { NavigationViewCoordinator(SettingsCoordinator()) @@ -50,6 +54,6 @@ final class HomeCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { - HomeView() + HomeView(viewModel: .init()) } } diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift index a68679fd..147f9c05 100644 --- a/Shared/Coordinators/ItemCoordinator.swift +++ b/Shared/Coordinators/ItemCoordinator.swift @@ -44,12 +44,16 @@ final class ItemCoordinator: NavigationCoordinatable { NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto)) } + func makeSeason(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) } @ViewBuilder func makeStart() -> some View { - ItemNavigationView(item: itemDto) + ItemView(item: itemDto) } } diff --git a/Shared/Coordinators/LibraryCoordinator.swift b/Shared/Coordinators/LibraryCoordinator.swift index 5dd3a52b..2c637206 100644 --- a/Shared/Coordinators/LibraryCoordinator.swift +++ b/Shared/Coordinators/LibraryCoordinator.swift @@ -23,10 +23,14 @@ final class LibraryCoordinator: NavigationCoordinatable { var search = makeSearch @Route(.modal) var filter = makeFilter - @Route(.push) - var item = makeItem - @Route(.modal) - var modalItem = makeModalItem + + #if os(tvOS) + @Route(.modal) + var item = makeModalItem + #else + @Route(.push) + var item = makeItem + #endif let viewModel: LibraryViewModel let title: String diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 7b08685e..3d75241b 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -63,7 +63,7 @@ final class SettingsCoordinator: NavigationCoordinatable { @ViewBuilder func makeAbout() -> some View { - AboutView() + AboutAppView() } #if !os(tvOS) diff --git a/Shared/Extensions/BlurHashDecode.swift b/Shared/Extensions/BlurHashDecode.swift deleted file mode 100644 index 925c8b21..00000000 --- a/Shared/Extensions/BlurHashDecode.swift +++ /dev/null @@ -1,165 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import UIKit - -// https://github.com/woltapp/blurhash/tree/master/Swift - -public extension UIImage { - convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { - guard blurHash.count >= 6 else { return nil } - - let sizeFlag = String(blurHash[0]).decode83() - let numY = (sizeFlag / 9) + 1 - let numX = (sizeFlag % 9) + 1 - - let quantisedMaximumValue = String(blurHash[1]).decode83() - let maximumValue = Float(quantisedMaximumValue + 1) / 166 - - guard blurHash.count == 4 + 2 * numX * numY else { return nil } - - let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in - if i == 0 { - let value = String(blurHash[2 ..< 6]).decode83() - return decodeDC(value) - } else { - let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83() - return decodeAC(value, maximumValue: maximumValue * punch) - } - } - - let width = Int(size.width) - let height = Int(size.height) - let bytesPerRow = width * 3 - guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } - CFDataSetLength(data, bytesPerRow * height) - guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } - - for y in 0 ..< height { - for x in 0 ..< width { - var r: Float = 0 - var g: Float = 0 - var b: Float = 0 - - for j in 0 ..< numY { - for i in 0 ..< numX { - let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) - let colour = colours[i + j * numX] - r += colour.0 * basis - g += colour.1 * basis - b += colour.2 * basis - } - } - - let intR = UInt8(linearTosRGB(r)) - let intG = UInt8(linearTosRGB(g)) - let intB = UInt8(linearTosRGB(b)) - - pixels[3 * x + 0 + y * bytesPerRow] = intR - pixels[3 * x + 1 + y * bytesPerRow] = intG - pixels[3 * x + 2 + y * bytesPerRow] = intB - } - } - - let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) - - guard let provider = CGDataProvider(data: data) else { return nil } - guard let cgImage = CGImage( - width: width, - height: height, - bitsPerComponent: 8, - bitsPerPixel: 24, - bytesPerRow: bytesPerRow, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: bitmapInfo, - provider: provider, - decode: nil, - shouldInterpolate: true, - intent: .defaultIntent - ) else { return nil } - - self.init(cgImage: cgImage) - } -} - -private func decodeDC(_ value: Int) -> (Float, Float, Float) { - let intR = value >> 16 - let intG = (value >> 8) & 255 - let intB = value & 255 - return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) -} - -private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { - let quantR = value / (19 * 19) - let quantG = (value / 19) % 19 - let quantB = value % 19 - - let rgb = ( - signPow((Float(quantR) - 9) / 9, 2) * maximumValue, - signPow((Float(quantG) - 9) / 9, 2) * maximumValue, - signPow((Float(quantB) - 9) / 9, 2) * maximumValue - ) - - return rgb -} - -private func signPow(_ value: Float, _ exp: Float) -> Float { - copysign(pow(abs(value), exp), value) -} - -private func linearTosRGB(_ value: Float) -> Int { - let v = max(0, min(1, value)) - if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } -} - -private func sRGBToLinear(_ value: Type) -> Float { - let v = Float(Int64(value)) / 255 - if v <= 0.04045 { return v / 12.92 } else { return pow((v + 0.055) / 1.055, 2.4) } -} - -private let encodeCharacters: [String] = { - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } -}() - -private let decodeCharacters: [String: Int] = { - var dict: [String: Int] = [:] - for (index, character) in encodeCharacters.enumerated() { - dict[character] = index - } - return dict -}() - -extension String { - func decode83() -> Int { - var value: Int = 0 - for character in self { - if let digit = decodeCharacters[String(character)] { - value = value * 83 + digit - } - } - return value - } -} - -private extension String { - subscript(offset: Int) -> Character { - self[index(startIndex, offsetBy: offset)] - } - - subscript(bounds: CountableClosedRange) -> Substring { - let start = index(startIndex, offsetBy: bounds.lowerBound) - let end = index(startIndex, offsetBy: bounds.upperBound) - return self[start ... end] - } - - subscript(bounds: CountableRange) -> Substring { - let start = index(startIndex, offsetBy: bounds.lowerBound) - let end = index(startIndex, offsetBy: bounds.upperBound) - return self[start ..< end] - } -} diff --git a/Shared/Extensions/ColorExtension.swift b/Shared/Extensions/ColorExtension.swift index 558aee22..6bbab868 100644 --- a/Shared/Extensions/ColorExtension.swift +++ b/Shared/Extensions/ColorExtension.swift @@ -20,8 +20,8 @@ public extension Color { #else static let systemFill = Color(UIColor.systemFill) static let systemBackground = Color(UIColor.systemBackground) - static let secondarySystemFill = Color(UIColor.secondarySystemBackground) - static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground) + static let secondarySystemFill = Color(UIColor.secondarySystemFill) + static let tertiarySystemFill = Color(UIColor.tertiarySystemFill) #endif } diff --git a/Shared/Extensions/Defaults+Workaround.swift b/Shared/Extensions/Defaults+Workaround.swift old mode 100644 new mode 100755 diff --git a/Shared/Extensions/FontExtensions.swift b/Shared/Extensions/FontExtensions.swift new file mode 100644 index 00000000..d8c2670c --- /dev/null +++ b/Shared/Extensions/FontExtensions.swift @@ -0,0 +1,42 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension Font { + func toUIFont() -> UIFont { + switch self { + #if !os(tvOS) + case .largeTitle: + return UIFont.preferredFont(forTextStyle: .largeTitle) + #endif + case .title: + return UIFont.preferredFont(forTextStyle: .title1) + case .title2: + return UIFont.preferredFont(forTextStyle: .title2) + case .title3: + return UIFont.preferredFont(forTextStyle: .title3) + case .headline: + return UIFont.preferredFont(forTextStyle: .headline) + case .subheadline: + return UIFont.preferredFont(forTextStyle: .subheadline) + case .callout: + return UIFont.preferredFont(forTextStyle: .callout) + case .caption: + return UIFont.preferredFont(forTextStyle: .caption1) + case .caption2: + return UIFont.preferredFont(forTextStyle: .caption2) + case .footnote: + return UIFont.preferredFont(forTextStyle: .footnote) + case .body: + return UIFont.preferredFont(forTextStyle: .body) + default: + return UIFont.preferredFont(forTextStyle: .body) + } + } +} diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Images.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Images.swift new file mode 100644 index 00000000..da8c7bd7 --- /dev/null +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Images.swift @@ -0,0 +1,91 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import UIKit + +extension BaseItemDto { + + // MARK: Item Images + + func imageURL( + _ type: ImageType, + maxWidth: Int + ) -> URL { + _imageURL(type, maxWidth: maxWidth, itemID: id ?? "") + } + + func imageURL( + _ type: ImageType, + maxWidth: CGFloat + ) -> URL { + _imageURL(type, maxWidth: Int(maxWidth), itemID: id ?? "") + } + + func blurHash(_ type: ImageType) -> String? { + guard type != .logo else { return nil } + if let tag = imageTags?[type.rawValue], let taggedBlurHash = imageBlurHashes?[type]?[tag] { + return taggedBlurHash + } else if let firstBlurHash = imageBlurHashes?[type]?.values.first { + return firstBlurHash + } + + return nil + } + + func imageSource(_ type: ImageType, maxWidth: Int) -> ImageSource { + _imageSource(type, maxWidth: maxWidth) + } + + func imageSource(_ type: ImageType, maxWidth: CGFloat) -> ImageSource { + _imageSource(type, maxWidth: Int(maxWidth)) + } + + // MARK: Series Images + + func seriesImageURL(_ type: ImageType, maxWidth: Int) -> URL { + _imageURL(type, maxWidth: maxWidth, itemID: seriesId ?? "") + } + + func seriesImageURL(_ type: ImageType, maxWidth: CGFloat) -> URL { + _imageURL(type, maxWidth: Int(maxWidth), itemID: seriesId ?? "") + } + + func seriesImageSource(_ type: ImageType, maxWidth: Int) -> ImageSource { + let url = _imageURL(type, maxWidth: maxWidth, itemID: seriesId ?? "") + return ImageSource(url: url, blurHash: nil) + } + + func seriesImageSource(_ type: ImageType, maxWidth: CGFloat) -> ImageSource { + seriesImageSource(type, maxWidth: Int(maxWidth)) + } + + // MARK: Fileprivate + + fileprivate func _imageURL( + _ type: ImageType, + maxWidth: Int, + itemID: String + ) -> URL { + let scaleWidth = UIScreen.main.scale(maxWidth) + let tag = imageTags?[type.rawValue] + return ImageAPI.getItemImageWithRequestBuilder( + itemId: itemID, + imageType: type, + maxWidth: scaleWidth, + tag: tag + ).url + } + + fileprivate func _imageSource(_ type: ImageType, maxWidth: Int) -> ImageSource { + let url = _imageURL(type, maxWidth: maxWidth, itemID: id ?? "") + let blurHash = blurHash(type) + return ImageSource(url: url, blurHash: blurHash) + } +} diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift new file mode 100644 index 00000000..542c80a2 --- /dev/null +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift @@ -0,0 +1,74 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation +import JellyfinAPI +import UIKit + +// MARK: PortraitPoster + +extension BaseItemDto: PortraitPoster { + + var title: String { + switch type { + case .episode: + return seriesName ?? displayName + default: + return displayName + } + } + + var subtitle: String? { + switch type { + case .episode: + return seasonEpisodeLocator + default: + return nil + } + } + + var showTitle: Bool { + switch type { + case .episode, .series, .movie, .boxSet: + return Defaults[.showPosterLabels] + default: + return true + } + } + + func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { + switch type { + case .episode: + return seriesImageSource(.primary, maxWidth: maxWidth) + default: + return imageSource(.primary, maxWidth: maxWidth) + } + } +} + +// MARK: LandscapePoster + +extension BaseItemDto { + func landscapePosterImageSources(maxWidth: CGFloat) -> [ImageSource] { + switch type { + case .episode: + // TODO: Set episode image preference based on defaults + return [ + seriesImageSource(.thumb, maxWidth: maxWidth), + seriesImageSource(.backdrop, maxWidth: maxWidth), + imageSource(.primary, maxWidth: maxWidth), + ] + default: + return [ + imageSource(.thumb, maxWidth: maxWidth), + imageSource(.backdrop, maxWidth: maxWidth), + ] + } + } +} diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift deleted file mode 100644 index c713e11d..00000000 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Foundation -import JellyfinAPI - -// MARK: PortraitImageStackable - -extension BaseItemDto: PortraitImageStackable { - public var portraitImageID: String { - id ?? "no id" - } - - public func imageURLConstructor(maxWidth: Int) -> URL { - switch self.itemType { - case .episode: - return getSeriesPrimaryImage(maxWidth: maxWidth) - default: - return self.getPrimaryImage(maxWidth: maxWidth) - } - } - - public var title: String { - switch self.itemType { - case .episode: - return self.seriesName ?? self.name ?? "" - default: - return self.name ?? "" - } - } - - public var subtitle: String? { - switch self.itemType { - case .episode: - return getEpisodeLocator() - default: - return nil - } - } - - public var blurHash: String { - self.getPrimaryImageBlurHash() - } - - public var failureInitials: String { - guard let name = self.name else { return "" } - let initials = name.split(separator: " ").compactMap { String($0).first } - return String(initials) - } - - public var showTitle: Bool { - switch self.itemType { - case .episode, .series, .movie, .boxset: - return Defaults[.showPosterLabels] - default: - return true - } - } -} diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index 833f3bbd..a58b0d8c 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -125,21 +125,21 @@ extension BaseItemDto { modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams // TODO: other forms of media subtitle - if self.itemType == .episode { - if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { + if self.type == .episode { + if let seriesName = self.seriesName, let episodeLocator = self.episodeLocator { subtitle = "\(seriesName) - \(episodeLocator)" } } let subtitlesEnabled = defaultSubtitleStream != nil - let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode + let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && type == .episode let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay let overlayType = Defaults[.overlayType] - let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode - let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode + let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && type == .episode + let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && type == .episode var fileName: String? if let lastInPath = currentMediaSource.path?.split(separator: "/").last { @@ -155,6 +155,7 @@ extension BaseItemDto { hlsStreamURL: hlsStreamURL, streamType: streamType, response: response, + videoStream: videoStream!, audioStreams: audioStreams, subtitleStreams: subtitleStreams, chapters: modifiedSelfItem.chapters ?? [], @@ -292,21 +293,21 @@ extension BaseItemDto { modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams // TODO: other forms of media subtitle - if self.itemType == .episode { - if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { + if self.type == .episode { + if let seriesName = self.seriesName, let episodeLocator = self.episodeLocator { subtitle = "\(seriesName) - \(episodeLocator)" } } let subtitlesEnabled = defaultSubtitleStream != nil - let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode + let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && type == .episode let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay let overlayType = Defaults[.overlayType] - let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode - let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode + let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && type == .episode + let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && type == .episode var fileName: String? if let lastInPath = currentMediaSource.path?.split(separator: "/").last { @@ -322,6 +323,7 @@ extension BaseItemDto { hlsStreamURL: hlsStreamURL, streamType: streamType, response: response, + videoStream: videoStream!, audioStreams: audioStreams, subtitleStreams: subtitleStreams, chapters: modifiedSelfItem.chapters ?? [], diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index fb786b9f..c3fe1018 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -10,175 +10,22 @@ import Foundation import JellyfinAPI import UIKit -// 001fC^ = dark grey plain blurhash +extension BaseItemDto: Identifiable {} -public extension BaseItemDto { - // MARK: Images +extension BaseItemDto { - func getSeriesBackdropImageBlurHash() -> String { - let imgURL = getSeriesBackdropImage(maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"], - let hash = imageBlurHashes?.backdrop?[imgTag] - else { - return "001fC^" - } - - return hash + var episodeLocator: String? { + guard let episodeNo = indexNumber else { return nil } + return L10n.episodeNumber(episodeNo) } - func getSeriesPrimaryImageBlurHash() -> String { - let imgURL = getSeriesPrimaryImage(maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"], - let hash = imageBlurHashes?.primary?[imgTag] - else { - return "001fC^" - } - - return hash - } - - func getPrimaryImageBlurHash() -> String { - let imgURL = getPrimaryImage(maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"], - let hash = imageBlurHashes?.primary?[imgTag] - else { - return "001fC^" - } - - return hash - } - - func getBackdropImageBlurHash() -> String { - let imgURL = getBackdropImage(maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"] else { - return "001fC^" - } - - if imgURL.queryParameters?[ImageType.backdrop.rawValue] == nil { - if itemType == .episode { - return imageBlurHashes?.backdrop?.values.first ?? "001fC^" - } else { - return imageBlurHashes?.backdrop?[imgTag] ?? "001fC^" - } - } else { - return imageBlurHashes?.primary?[imgTag] ?? "001fC^" - } - } - - func getBackdropImage(maxWidth: Int) -> URL { - var imageType = ImageType.backdrop - var imageTag: String? - var imageItemId = id ?? "" - - if primaryImageAspectRatio ?? 0.0 < 1.0 { - if !(backdropImageTags?.isEmpty ?? true) { - imageTag = backdropImageTags?.first - } - } else { - imageType = .primary - imageTag = imageTags?[ImageType.primary.rawValue] ?? "" - } - - if imageTag == nil || imageItemId.isEmpty { - if !(parentBackdropImageTags?.isEmpty ?? true) { - imageTag = parentBackdropImageTags?.first - imageItemId = parentBackdropItemId ?? "" - } - } - - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - - let urlString = ImageAPI.getItemImageWithRequestBuilder( - itemId: imageItemId, - imageType: imageType, - maxWidth: Int(x), - quality: 96, - tag: imageTag - ).URLString - return URL(string: urlString)! - } - - func getThumbImage(maxWidth: Int) -> URL { - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - - let urlString = ImageAPI.getItemImageWithRequestBuilder( - itemId: id ?? "", - imageType: .thumb, - maxWidth: Int(x), - quality: 96 - ).URLString - return URL(string: urlString)! - } - - func getEpisodeLocator() -> String? { + var seasonEpisodeLocator: String? { if let seasonNo = parentIndexNumber, let episodeNo = indexNumber { return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo)) } return nil } - func getSeriesBackdropImage(maxWidth: Int) -> URL { - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder( - itemId: parentBackdropItemId ?? "", - imageType: .backdrop, - maxWidth: Int(x), - quality: 96, - tag: parentBackdropImageTags?.first - ).URLString - return URL(string: urlString)! - } - - func getSeriesPrimaryImage(maxWidth: Int) -> URL { - guard let seriesId = seriesId else { - return getPrimaryImage(maxWidth: maxWidth) - } - - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder( - itemId: seriesId, - imageType: .primary, - maxWidth: Int(x), - quality: 96, - tag: seriesPrimaryImageTag - ).URLString - return URL(string: urlString)! - } - - func getSeriesThumbImage(maxWidth: Int) -> URL { - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder( - itemId: seriesId ?? "", - imageType: .thumb, - maxWidth: Int(x), - quality: 96, - tag: seriesPrimaryImageTag - ).URLString - return URL(string: urlString)! - } - - func getPrimaryImage(maxWidth: Int) -> URL { - let imageType = ImageType.primary - var imageTag = imageTags?[ImageType.primary.rawValue] ?? "" - var imageItemId = id ?? "" - - if imageTag.isEmpty || imageItemId.isEmpty { - imageTag = seriesPrimaryImageTag ?? "" - imageItemId = seriesId ?? "" - } - - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - - let urlString = ImageAPI.getItemImageWithRequestBuilder( - itemId: imageItemId, - imageType: imageType, - maxWidth: Int(x), - quality: 96, - tag: imageTag - ).URLString - return URL(string: urlString)! - } - // MARK: Calculations func getItemRuntime() -> String? { @@ -238,61 +85,8 @@ public extension BaseItemDto { return 0 } - // MARK: ItemType - - enum ItemType: String { - case movie = "Movie" - case season = "Season" - case episode = "Episode" - case series = "Series" - case boxset = "BoxSet" - case collectionFolder = "CollectionFolder" - case folder = "Folder" - case liveTV = "LiveTV" - - case unknown - - var showDetails: Bool { - switch self { - case .season, .series: - return false - default: - return true - } - } - - public init?(rawValue: String) { - let lowerCase = rawValue.lowercased() - switch lowerCase { - case "movie": self = .movie - case "season": self = .season - case "episode": self = .episode - case "series": self = .series - case "boxset": self = .boxset - case "collectionfolder": self = .collectionFolder - case "folder": self = .folder - case "livetv": self = .liveTV - default: self = .unknown - } - } - } - - var itemType: ItemType { - guard let originalType = type, let knownType = ItemType(rawValue: originalType.rawValue) else { return .unknown } - return knownType - } - - // MARK: PortraitHeaderViewURL - - func portraitHeaderViewURL(maxWidth: Int) -> URL { - switch itemType { - case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV: - return getPrimaryImage(maxWidth: maxWidth) - case .episode: - return getSeriesPrimaryImage(maxWidth: maxWidth) - case .unknown: - return getPrimaryImage(maxWidth: maxWidth) - } + var displayName: String { + name ?? "--" } // MARK: ItemDetail @@ -329,13 +123,13 @@ public extension BaseItemDto { if !audioStreams.isEmpty { let audioList = audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } - .joined(separator: ", ") + .joined(separator: "\n") mediaItems.append(ItemDetail(title: L10n.audio, content: audioList)) } if !subtitleStreams.isEmpty { let subtitleList = subtitleStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } - .joined(separator: ", ") + .joined(separator: "\n") mediaItems.append(ItemDetail(title: L10n.subtitles, content: subtitleList)) } } @@ -343,6 +137,14 @@ public extension BaseItemDto { return mediaItems } + var subtitleStreams: [MediaStream] { + mediaStreams?.filter { $0.type == .subtitle } ?? [] + } + + var audioStreams: [MediaStream] { + mediaStreams?.filter { $0.type == .audio } ?? [] + } + // MARK: Missing and Unaired var missing: Bool { @@ -370,6 +172,13 @@ public extension BaseItemDto { return dateFormatter.string(from: premiereDate) } + var premiereDateYear: String? { + guard let premiereDate = premiereDate else { return nil } + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "YYYY" + return dateFormatter.string(from: premiereDate) + } + // MARK: Chapter Images func getChapterImage(maxWidth: Int) -> [URL] { @@ -389,4 +198,52 @@ public extension BaseItemDto { return chapterImageURLs } + + // TODO: Don't use spoof objects as a placeholder or no results + + static var placeHolder: BaseItemDto { + .init( + name: "Placeholder", + id: "1", + overview: String(repeating: "a", count: 100), + indexNumber: 20 + ) + } + + static var noResults: BaseItemDto { + .init(name: L10n.noResults) + } +} + +extension BaseItemDtoImageBlurHashes { + subscript(imageType: ImageType) -> [String: String]? { + switch imageType { + case .primary: + return primary + case .art: + return art + case .backdrop: + return backdrop + case .banner: + return banner + case .logo: + return logo + case .thumb: + return thumb + case .disc: + return disc + case .box: + return box + case .screenshot: + return screenshot + case .menu: + return menu + case .chapter: + return chapter + case .boxRear: + return boxRear + case .profile: + return profile + } + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift new file mode 100644 index 00000000..9cebba93 --- /dev/null +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift @@ -0,0 +1,46 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import UIKit + +// MARK: PortraitImageStackable + +extension BaseItemPerson: PortraitPoster { + + var title: String { + self.name ?? "--" + } + + var subtitle: String? { + self.firstRole + } + + var showTitle: Bool { + true + } + + func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { + let scaleWidth = UIScreen.main.scale(maxWidth) + let url = ImageAPI.getItemImageWithRequestBuilder( + itemId: id ?? "", + imageType: .primary, + maxWidth: scaleWidth, + tag: primaryImageTag + ).url + + var blurHash: String? + + if let tag = primaryImageTag, let taggedBlurHash = imageBlurHashes?.primary?[tag] { + blurHash = taggedBlurHash + } + + return ImageSource(url: url, blurHash: blurHash) + } +} diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift index 338840d5..68395287 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift @@ -12,39 +12,13 @@ import UIKit extension BaseItemPerson { - // MARK: Get Image - - func getImage(baseURL: String, maxWidth: Int) -> URL { - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - - let urlString = ImageAPI.getItemImageWithRequestBuilder( - itemId: id ?? "", - imageType: .primary, - maxWidth: Int(x), - quality: 96, - tag: primaryImageTag - ).URLString - return URL(string: urlString)! - } - - func getBlurHash() -> String { - let imgURL = getImage(baseURL: "", maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"], - let hash = imageBlurHashes?.primary?[imgTag] - else { - return "001fC^" - } - - return hash - } - // MARK: First Role // Jellyfin will grab all roles the person played in the show which makes the role // text too long. This will grab the first role which: // - assumes that the most important role is the first // - will also grab the last "()" instance, like "(voice)" - func firstRole() -> String? { + var firstRole: String? { guard let role = self.role else { return nil } let split = role.split(separator: "/") guard split.count > 1 else { return role } @@ -61,56 +35,18 @@ extension BaseItemPerson { return final } -} - -// MARK: PortraitImageStackable - -extension BaseItemPerson: PortraitImageStackable { - public var portraitImageID: String { - (id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials - } - - public func imageURLConstructor(maxWidth: Int) -> URL { - self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth) - } - - public var title: String { - self.name ?? "" - } - - public var subtitle: String? { - self.firstRole() - } - - public var blurHash: String { - self.getBlurHash() - } - - public var failureInitials: String { - guard let name = self.name else { return "" } - let initials = name.split(separator: " ").compactMap { String($0).first } - return String(initials) - } - - public var showTitle: Bool { - true - } -} - -// MARK: DiplayedType - -extension BaseItemPerson { // Only displayed person types. - // Will ignore people like "GuestStar" - enum DisplayedType: String, CaseIterable { + // Will ignore types like "GuestStar" + enum DisplayedType: String { case actor = "Actor" case director = "Director" case writer = "Writer" case producer = "Producer" + } - static var allCasesRaw: [String] { - self.allCases.map(\.rawValue) - } + var isDisplayed: Bool { + guard let type = type else { return false } + return DisplayedType(rawValue: type) != nil } } diff --git a/Shared/Extensions/ViewExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/RequestBuilderExtensions.swift similarity index 71% rename from Shared/Extensions/ViewExtensions.swift rename to Shared/Extensions/JellyfinAPIExtensions/RequestBuilderExtensions.swift index a636b664..6c76bfa5 100644 --- a/Shared/Extensions/ViewExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/RequestBuilderExtensions.swift @@ -7,10 +7,10 @@ // import Foundation -import SwiftUI +import JellyfinAPI -extension View { - func eraseToAnyView() -> AnyView { - AnyView(self) +extension RequestBuilder where T == URL { + var url: URL { + URL(string: URLString)! } } diff --git a/Shared/Extensions/StringExtensions.swift b/Shared/Extensions/StringExtensions.swift index 00bd1755..d71d04d4 100644 --- a/Shared/Extensions/StringExtensions.swift +++ b/Shared/Extensions/StringExtensions.swift @@ -37,4 +37,9 @@ extension String { var text: Text { Text(self) } + + var initials: String { + let initials = self.split(separator: " ").compactMap(\.first) + return String(initials) + } } diff --git a/Shared/Extensions/UIDeviceExtensions.swift b/Shared/Extensions/UIDeviceExtensions.swift index 40467175..8ccea68a 100644 --- a/Shared/Extensions/UIDeviceExtensions.swift +++ b/Shared/Extensions/UIDeviceExtensions.swift @@ -12,4 +12,32 @@ extension UIDevice { static var vendorUUIDString: String { current.identifierForVendor!.uuidString } + + static var isIPad: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } + + static var isPhone: Bool { + UIDevice.current.userInterfaceIdiom == .phone + } + + #if os(iOS) + static var isPortrait: Bool { + UIDevice.current.orientation.isPortrait + } + + static var isLandscape: Bool { + isIPad || UIDevice.current.orientation.isLandscape + } + + static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) { + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(type) + } + + static func impact(_ type: UIImpactFeedbackGenerator.FeedbackStyle) { + let generator = UIImpactFeedbackGenerator(style: type) + generator.impactOccurred() + } + #endif } diff --git a/WidgetExtension/JellyfinWidget.swift b/Shared/Extensions/UIScreenExtensions.swift similarity index 60% rename from WidgetExtension/JellyfinWidget.swift rename to Shared/Extensions/UIScreenExtensions.swift index 2764028b..beeb5987 100644 --- a/WidgetExtension/JellyfinWidget.swift +++ b/Shared/Extensions/UIScreenExtensions.swift @@ -6,13 +6,14 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // -import SwiftUI -import WidgetKit +import UIKit -@main -struct JellyfinWidgetBundle: WidgetBundle { - @WidgetBundleBuilder - var body: some Widget { - NextUpWidget() +extension UIScreen { + func scale(_ x: Int) -> Int { + Int(nativeScale) * x + } + + func scale(_ x: CGFloat) -> Int { + Int(nativeScale * x) } } diff --git a/Shared/Extensions/UIScrollViewExtensions.swift b/Shared/Extensions/UIScrollViewExtensions.swift new file mode 100644 index 00000000..59651d6f --- /dev/null +++ b/Shared/Extensions/UIScrollViewExtensions.swift @@ -0,0 +1,16 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import UIKit + +extension UIScrollView { + func scrollToTop(animated: Bool = true) { + let desiredOffset = CGPoint(x: 0, y: 0) + setContentOffset(desiredOffset, animated: animated) + } +} diff --git a/Shared/Extensions/ViewExtensions/BackgroundParallaxHeaderModifier.swift b/Shared/Extensions/ViewExtensions/BackgroundParallaxHeaderModifier.swift new file mode 100644 index 00000000..e2d1afce --- /dev/null +++ b/Shared/Extensions/ViewExtensions/BackgroundParallaxHeaderModifier.swift @@ -0,0 +1,40 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct BackgroundParallaxHeaderModifier: ViewModifier { + + @Binding + var scrollViewOffset: CGFloat + + let height: CGFloat + let multiplier: CGFloat + let header: () -> Header + + init( + _ scrollViewOffset: Binding, + height: CGFloat, + multiplier: CGFloat = 1, + @ViewBuilder header: @escaping () -> Header + ) { + self._scrollViewOffset = scrollViewOffset + self.height = height + self.multiplier = multiplier + self.header = header + } + + func body(content: Content) -> some View { + content.background(alignment: .top) { + header() + .offset(y: scrollViewOffset > 0 ? -scrollViewOffset * multiplier : 0) + .scaleEffect(scrollViewOffset < 0 ? (height - scrollViewOffset) / height : 1, anchor: .top) + .ignoresSafeArea() + } + } +} diff --git a/Shared/Extensions/ViewExtensions/BottomEdgeGradientModifier.swift b/Shared/Extensions/ViewExtensions/BottomEdgeGradientModifier.swift new file mode 100644 index 00000000..0f6934da --- /dev/null +++ b/Shared/Extensions/ViewExtensions/BottomEdgeGradientModifier.swift @@ -0,0 +1,35 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct BottomEdgeGradientModifier: ViewModifier { + + let bottomColor: Color + + func body(content: Content) -> some View { + VStack(spacing: 0) { + content + .overlay { + bottomColor + .mask { + LinearGradient( + stops: [ + .init(color: .clear, location: 0.8), + .init(color: .white, location: 0.95), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + + bottomColor + } + } +} diff --git a/Shared/Extensions/ViewExtensions/ScrollViewOffsetModifier.swift b/Shared/Extensions/ViewExtensions/ScrollViewOffsetModifier.swift new file mode 100644 index 00000000..6ae59916 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/ScrollViewOffsetModifier.swift @@ -0,0 +1,39 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Introspect +import SwiftUI + +struct ScrollViewOffsetModifier: ViewModifier { + + @Binding + var scrollViewOffset: CGFloat + + private let scrollViewDelegate: ScrollViewDelegate? + + init(scrollViewOffset: Binding) { + self._scrollViewOffset = scrollViewOffset + self.scrollViewDelegate = ScrollViewDelegate() + self.scrollViewDelegate?.parent = self + } + + func body(content: Content) -> some View { + content.introspectScrollView { scrollView in + scrollView.delegate = scrollViewDelegate + } + } + + private class ScrollViewDelegate: NSObject, UIScrollViewDelegate { + + var parent: ScrollViewOffsetModifier? + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + parent?.scrollViewOffset = scrollView.contentOffset.y + } + } +} diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift new file mode 100644 index 00000000..09822496 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -0,0 +1,77 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation +import SwiftUI + +extension View { + @inlinable + func eraseToAnyView() -> AnyView { + AnyView(self) + } + + public func inverseMask(_ mask: M) -> some View { + // exchange foreground and background + let inversed = mask + .foregroundColor(.black) // hide foreground + .background(Color.white) // let the background stand out + .compositingGroup() + .luminanceToAlpha() + return self.mask(inversed) + } + + // From: https://www.avanderlee.com/swiftui/conditional-view-modifier/ + @ViewBuilder + @inlinable + func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } + + @ViewBuilder + @inlinable + func `if`(_ condition: Bool, transformIf: (Self) -> Content, transformElse: (Self) -> Content) -> some View { + if condition { + transformIf(self) + } else { + transformElse(self) + } + } + + /// Applies Portrait Poster frame with proper corner radius ratio against the width + func portraitPoster(width: CGFloat) -> some View { + self.frame(width: width, height: width * 1.5) + .cornerRadius((width * 1.5) / 40) + } + + @inlinable + func padding2(_ edges: Edge.Set = .all) -> some View { + self.padding(edges) + .padding(edges) + } + + func scrollViewOffset(_ scrollViewOffset: Binding) -> some View { + self.modifier(ScrollViewOffsetModifier(scrollViewOffset: scrollViewOffset)) + } + + func backgroundParallaxHeader( + _ scrollViewOffset: Binding, + height: CGFloat, + multiplier: CGFloat = 1, + @ViewBuilder header: @escaping () -> Header + ) -> some View { + self.modifier(BackgroundParallaxHeaderModifier(scrollViewOffset, height: height, multiplier: multiplier, header: header)) + } + + func bottomEdgeGradient(bottomColor: Color) -> some View { + self.modifier(BottomEdgeGradientModifier(bottomColor: bottomColor)) + } +} diff --git a/Shared/Generated/LocalizedLookup.swift b/Shared/Generated/LocalizedLookup.swift deleted file mode 100644 index e42cc58b..00000000 --- a/Shared/Generated/LocalizedLookup.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Foundation - -class TranslationService { - - static let shared = TranslationService() - - func lookupTranslation(forKey key: String, inTable table: String) -> String { - - let expectedValue = Bundle.main.localizedString(forKey: key, value: nil, table: table) - - if expectedValue == key || NSLocale.preferredLanguages.first == "en" { - guard let path = Bundle.main.path(forResource: "en", ofType: "lproj"), - let bundle = Bundle(path: path) else { return expectedValue } - - return NSLocalizedString(key, bundle: bundle, comment: "") - } else { - return expectedValue - } - } -} diff --git a/Shared/Generated/Strings.swift b/Shared/Generated/Strings.swift deleted file mode 100644 index 29c82ada..00000000 --- a/Shared/Generated/Strings.swift +++ /dev/null @@ -1,466 +0,0 @@ -// swiftlint:disable all -// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen - -import Foundation - -// swiftlint:disable superfluous_disable_command file_length implicit_return - -// MARK: - Strings - -// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces -internal enum L10n { - /// About - internal static var about: String { return L10n.tr("Localizable", "about") } - /// Accessibility - internal static var accessibility: String { return L10n.tr("Localizable", "accessibility") } - /// Add URL - internal static var addURL: String { return L10n.tr("Localizable", "addURL") } - /// Airs %s - internal static func airWithDate(_ p1: UnsafePointer) -> String { - return L10n.tr("Localizable", "airWithDate", p1) - } - /// All Genres - internal static var allGenres: String { return L10n.tr("Localizable", "allGenres") } - /// All Media - internal static var allMedia: String { return L10n.tr("Localizable", "allMedia") } - /// Appearance - internal static var appearance: String { return L10n.tr("Localizable", "appearance") } - /// Apply - internal static var apply: String { return L10n.tr("Localizable", "apply") } - /// Audio - internal static var audio: String { return L10n.tr("Localizable", "audio") } - /// Audio & Captions - internal static var audioAndCaptions: String { return L10n.tr("Localizable", "audioAndCaptions") } - /// Audio Track - internal static var audioTrack: String { return L10n.tr("Localizable", "audioTrack") } - /// Authorize - internal static var authorize: String { return L10n.tr("Localizable", "authorize") } - /// Auto Play - internal static var autoPlay: String { return L10n.tr("Localizable", "autoPlay") } - /// Back - internal static var back: String { return L10n.tr("Localizable", "back") } - /// Cancel - internal static var cancel: String { return L10n.tr("Localizable", "cancel") } - /// Cannot connect to host - internal static var cannotConnectToHost: String { return L10n.tr("Localizable", "cannotConnectToHost") } - /// CAST - internal static var cast: String { return L10n.tr("Localizable", "cast") } - /// Cast & Crew - internal static var castAndCrew: String { return L10n.tr("Localizable", "castAndCrew") } - /// Change Server - internal static var changeServer: String { return L10n.tr("Localizable", "changeServer") } - /// Channels - internal static var channels: String { return L10n.tr("Localizable", "channels") } - /// Chapters - internal static var chapters: String { return L10n.tr("Localizable", "chapters") } - /// Cinematic Views - internal static var cinematicViews: String { return L10n.tr("Localizable", "cinematicViews") } - /// Close - internal static var close: String { return L10n.tr("Localizable", "close") } - /// Closed Captions - internal static var closedCaptions: String { return L10n.tr("Localizable", "closedCaptions") } - /// Compact - internal static var compact: String { return L10n.tr("Localizable", "compact") } - /// Confirm Close - internal static var confirmClose: String { return L10n.tr("Localizable", "confirmClose") } - /// Connect - internal static var connect: String { return L10n.tr("Localizable", "connect") } - /// Connect Manually - internal static var connectManually: String { return L10n.tr("Localizable", "connectManually") } - /// Connect to Jellyfin - internal static var connectToJellyfin: String { return L10n.tr("Localizable", "connectToJellyfin") } - /// Connect to a Jellyfin server - internal static var connectToJellyfinServer: String { return L10n.tr("Localizable", "connectToJellyfinServer") } - /// Connect to a Jellyfin server to get started - internal static var connectToJellyfinServerStart: String { return L10n.tr("Localizable", "connectToJellyfinServerStart") } - /// Connect to Server - internal static var connectToServer: String { return L10n.tr("Localizable", "connectToServer") } - /// Containers - internal static var containers: String { return L10n.tr("Localizable", "containers") } - /// Continue - internal static var `continue`: String { return L10n.tr("Localizable", "continue") } - /// Continue Watching - internal static var continueWatching: String { return L10n.tr("Localizable", "continueWatching") } - /// Current Position - internal static var currentPosition: String { return L10n.tr("Localizable", "currentPosition") } - /// Customize - internal static var customize: String { return L10n.tr("Localizable", "customize") } - /// Dark - internal static var dark: String { return L10n.tr("Localizable", "dark") } - /// Default Scheme - internal static var defaultScheme: String { return L10n.tr("Localizable", "defaultScheme") } - /// DIRECTOR - internal static var director: String { return L10n.tr("Localizable", "director") } - /// Discovered Servers - internal static var discoveredServers: String { return L10n.tr("Localizable", "discoveredServers") } - /// Display order - internal static var displayOrder: String { return L10n.tr("Localizable", "displayOrder") } - /// Edit Jump Lengths - internal static var editJumpLengths: String { return L10n.tr("Localizable", "editJumpLengths") } - /// Empty Next Up - internal static var emptyNextUp: String { return L10n.tr("Localizable", "emptyNextUp") } - /// Episodes - internal static var episodes: String { return L10n.tr("Localizable", "episodes") } - /// Error - internal static var error: String { return L10n.tr("Localizable", "error") } - /// Existing Server - internal static var existingServer: String { return L10n.tr("Localizable", "existingServer") } - /// Existing User - internal static var existingUser: String { return L10n.tr("Localizable", "existingUser") } - /// Experimental - internal static var experimental: String { return L10n.tr("Localizable", "experimental") } - /// Favorites - internal static var favorites: String { return L10n.tr("Localizable", "favorites") } - /// File - internal static var file: String { return L10n.tr("Localizable", "file") } - /// Filter Results - internal static var filterResults: String { return L10n.tr("Localizable", "filterResults") } - /// Filters - internal static var filters: String { return L10n.tr("Localizable", "filters") } - /// Genres - internal static var genres: String { return L10n.tr("Localizable", "genres") } - /// Home - internal static var home: String { return L10n.tr("Localizable", "home") } - /// Information - internal static var information: String { return L10n.tr("Localizable", "information") } - /// Items - internal static var items: String { return L10n.tr("Localizable", "items") } - /// Jump Backward - internal static var jumpBackward: String { return L10n.tr("Localizable", "jumpBackward") } - /// Jump Backward Length - internal static var jumpBackwardLength: String { return L10n.tr("Localizable", "jumpBackwardLength") } - /// Jump Forward - internal static var jumpForward: String { return L10n.tr("Localizable", "jumpForward") } - /// Jump Forward Length - internal static var jumpForwardLength: String { return L10n.tr("Localizable", "jumpForwardLength") } - /// Jump Gestures Enabled - internal static var jumpGesturesEnabled: String { return L10n.tr("Localizable", "jumpGesturesEnabled") } - /// %s seconds - internal static func jumpLengthSeconds(_ p1: UnsafePointer) -> String { - return L10n.tr("Localizable", "jumpLengthSeconds", p1) - } - /// Larger - internal static var larger: String { return L10n.tr("Localizable", "larger") } - /// Largest - internal static var largest: String { return L10n.tr("Localizable", "largest") } - /// Latest %@ - internal static func latestWithString(_ p1: Any) -> String { - return L10n.tr("Localizable", "latestWithString", String(describing: p1)) - } - /// Library - internal static var library: String { return L10n.tr("Localizable", "library") } - /// Light - internal static var light: String { return L10n.tr("Localizable", "light") } - /// Loading - internal static var loading: String { return L10n.tr("Localizable", "loading") } - /// Local Servers - internal static var localServers: String { return L10n.tr("Localizable", "localServers") } - /// Login - internal static var login: String { return L10n.tr("Localizable", "login") } - /// Login to %@ - internal static func loginToWithString(_ p1: Any) -> String { - return L10n.tr("Localizable", "loginToWithString", String(describing: p1)) - } - /// Media - internal static var media: String { return L10n.tr("Localizable", "media") } - /// Missing - internal static var missing: String { return L10n.tr("Localizable", "missing") } - /// Missing Items - internal static var missingItems: String { return L10n.tr("Localizable", "missingItems") } - /// More Like This - internal static var moreLikeThis: String { return L10n.tr("Localizable", "moreLikeThis") } - /// Movies - internal static var movies: String { return L10n.tr("Localizable", "movies") } - /// %d users - internal static func multipleUsers(_ p1: Int) -> String { - return L10n.tr("Localizable", "multipleUsers", p1) - } - /// Name - internal static var name: String { return L10n.tr("Localizable", "name") } - /// Networking - internal static var networking: String { return L10n.tr("Localizable", "networking") } - /// Network timed out - internal static var networkTimedOut: String { return L10n.tr("Localizable", "networkTimedOut") } - /// Next - internal static var next: String { return L10n.tr("Localizable", "next") } - /// Next Item - internal static var nextItem: String { return L10n.tr("Localizable", "nextItem") } - /// Next Up - internal static var nextUp: String { return L10n.tr("Localizable", "nextUp") } - /// No Cast devices found.. - internal static var noCastdevicesfound: String { return L10n.tr("Localizable", "noCastdevicesfound") } - /// No Codec - internal static var noCodec: String { return L10n.tr("Localizable", "noCodec") } - /// No episodes available - internal static var noEpisodesAvailable: String { return L10n.tr("Localizable", "noEpisodesAvailable") } - /// No local servers found - internal static var noLocalServersFound: String { return L10n.tr("Localizable", "noLocalServersFound") } - /// None - internal static var `none`: String { return L10n.tr("Localizable", "none") } - /// No overview available - internal static var noOverviewAvailable: String { return L10n.tr("Localizable", "noOverviewAvailable") } - /// No public Users - internal static var noPublicUsers: String { return L10n.tr("Localizable", "noPublicUsers") } - /// No results. - internal static var noResults: String { return L10n.tr("Localizable", "noResults") } - /// Normal - internal static var normal: String { return L10n.tr("Localizable", "normal") } - /// N/A - internal static var notAvailableSlash: String { return L10n.tr("Localizable", "notAvailableSlash") } - /// Type: %@ not implemented yet :( - internal static func notImplementedYetWithType(_ p1: Any) -> String { - return L10n.tr("Localizable", "notImplementedYetWithType", String(describing: p1)) - } - /// No title - internal static var noTitle: String { return L10n.tr("Localizable", "noTitle") } - /// Ok - internal static var ok: String { return L10n.tr("Localizable", "ok") } - /// 1 user - internal static var oneUser: String { return L10n.tr("Localizable", "oneUser") } - /// Operating System - internal static var operatingSystem: String { return L10n.tr("Localizable", "operatingSystem") } - /// Other - internal static var other: String { return L10n.tr("Localizable", "other") } - /// Other User - internal static var otherUser: String { return L10n.tr("Localizable", "otherUser") } - /// Overlay - internal static var overlay: String { return L10n.tr("Localizable", "overlay") } - /// Overlay Type - internal static var overlayType: String { return L10n.tr("Localizable", "overlayType") } - /// Overview - internal static var overview: String { return L10n.tr("Localizable", "overview") } - /// Page %1$@ of %2$@ - internal static func pageOfWithNumbers(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "pageOfWithNumbers", String(describing: p1), String(describing: p2)) - } - /// Password - internal static var password: String { return L10n.tr("Localizable", "password") } - /// Play - internal static var play: String { return L10n.tr("Localizable", "play") } - /// Play / Pause - internal static var playAndPause: String { return L10n.tr("Localizable", "playAndPause") } - /// Playback settings - internal static var playbackSettings: String { return L10n.tr("Localizable", "playbackSettings") } - /// Playback Speed - internal static var playbackSpeed: String { return L10n.tr("Localizable", "playbackSpeed") } - /// Player Gestures Lock Gesture Enabled - internal static var playerGesturesLockGestureEnabled: String { return L10n.tr("Localizable", "playerGesturesLockGestureEnabled") } - /// Play From Beginning - internal static var playFromBeginning: String { return L10n.tr("Localizable", "playFromBeginning") } - /// Play Next - internal static var playNext: String { return L10n.tr("Localizable", "playNext") } - /// Play Next Item - internal static var playNextItem: String { return L10n.tr("Localizable", "playNextItem") } - /// Play Previous Item - internal static var playPreviousItem: String { return L10n.tr("Localizable", "playPreviousItem") } - /// Present - internal static var present: String { return L10n.tr("Localizable", "present") } - /// Press Down for Menu - internal static var pressDownForMenu: String { return L10n.tr("Localizable", "pressDownForMenu") } - /// Previous Item - internal static var previousItem: String { return L10n.tr("Localizable", "previousItem") } - /// Programs - internal static var programs: String { return L10n.tr("Localizable", "programs") } - /// Public Users - internal static var publicUsers: String { return L10n.tr("Localizable", "publicUsers") } - /// Quick Connect - internal static var quickConnect: String { return L10n.tr("Localizable", "quickConnect") } - /// Quick Connect code - internal static var quickConnectCode: String { return L10n.tr("Localizable", "quickConnectCode") } - /// Invalid Quick Connect code - internal static var quickConnectInvalidError: String { return L10n.tr("Localizable", "quickConnectInvalidError") } - /// Note: Quick Connect not enabled - internal static var quickConnectNotEnabled: String { return L10n.tr("Localizable", "quickConnectNotEnabled") } - /// 1. Open the Jellyfin app on your phone or web browser and sign in with your account - internal static var quickConnectStep1: String { return L10n.tr("Localizable", "quickConnectStep1") } - /// 2. Open the user menu and go to the Quick Connect page - internal static var quickConnectStep2: String { return L10n.tr("Localizable", "quickConnectStep2") } - /// 3. Enter the following code: - internal static var quickConnectStep3: String { return L10n.tr("Localizable", "quickConnectStep3") } - /// Authorizing Quick Connect successful. Please continue on your other device. - internal static var quickConnectSuccessMessage: String { return L10n.tr("Localizable", "quickConnectSuccessMessage") } - /// Rated - internal static var rated: String { return L10n.tr("Localizable", "rated") } - /// Recently Added - internal static var recentlyAdded: String { return L10n.tr("Localizable", "recentlyAdded") } - /// Recommended - internal static var recommended: String { return L10n.tr("Localizable", "recommended") } - /// Refresh - internal static var refresh: String { return L10n.tr("Localizable", "refresh") } - /// Regular - internal static var regular: String { return L10n.tr("Localizable", "regular") } - /// Released - internal static var released: String { return L10n.tr("Localizable", "released") } - /// Remaining Time - internal static var remainingTime: String { return L10n.tr("Localizable", "remainingTime") } - /// Remove - internal static var remove: String { return L10n.tr("Localizable", "remove") } - /// Remove All Users - internal static var removeAllUsers: String { return L10n.tr("Localizable", "removeAllUsers") } - /// Remove From Resume - internal static var removeFromResume: String { return L10n.tr("Localizable", "removeFromResume") } - /// Report an Issue - internal static var reportIssue: String { return L10n.tr("Localizable", "reportIssue") } - /// Request a Feature - internal static var requestFeature: String { return L10n.tr("Localizable", "requestFeature") } - /// Reset - internal static var reset: String { return L10n.tr("Localizable", "reset") } - /// Reset App Settings - internal static var resetAppSettings: String { return L10n.tr("Localizable", "resetAppSettings") } - /// Reset User Settings - internal static var resetUserSettings: String { return L10n.tr("Localizable", "resetUserSettings") } - /// Resume 5 Second Offset - internal static var resume5SecondOffset: String { return L10n.tr("Localizable", "resume5SecondOffset") } - /// Retry - internal static var retry: String { return L10n.tr("Localizable", "retry") } - /// Runtime - internal static var runtime: String { return L10n.tr("Localizable", "runtime") } - /// Search - internal static var search: String { return L10n.tr("Localizable", "search") } - /// Search… - internal static var searchDots: String { return L10n.tr("Localizable", "searchDots") } - /// Searching… - internal static var searchingDots: String { return L10n.tr("Localizable", "searchingDots") } - /// Season - internal static var season: String { return L10n.tr("Localizable", "season") } - /// S%1$@:E%2$@ - internal static func seasonAndEpisode(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "seasonAndEpisode", String(describing: p1), String(describing: p2)) - } - /// Seasons - internal static var seasons: String { return L10n.tr("Localizable", "seasons") } - /// See All - internal static var seeAll: String { return L10n.tr("Localizable", "seeAll") } - /// Seek Slide Gesture Enabled - internal static var seekSlideGestureEnabled: String { return L10n.tr("Localizable", "seekSlideGestureEnabled") } - /// See More - internal static var seeMore: String { return L10n.tr("Localizable", "seeMore") } - /// Select Cast Destination - internal static var selectCastDestination: String { return L10n.tr("Localizable", "selectCastDestination") } - /// Series - internal static var series: String { return L10n.tr("Localizable", "series") } - /// Server - internal static var server: String { return L10n.tr("Localizable", "server") } - /// Server %s is already connected - internal static func serverAlreadyConnected(_ p1: UnsafePointer) -> String { - return L10n.tr("Localizable", "serverAlreadyConnected", p1) - } - /// Server %s already exists. Add new URL? - internal static func serverAlreadyExistsPrompt(_ p1: UnsafePointer) -> String { - return L10n.tr("Localizable", "serverAlreadyExistsPrompt", p1) - } - /// Server Details - internal static var serverDetails: String { return L10n.tr("Localizable", "serverDetails") } - /// Server Information - internal static var serverInformation: String { return L10n.tr("Localizable", "serverInformation") } - /// Servers - internal static var servers: String { return L10n.tr("Localizable", "servers") } - /// Server URL - internal static var serverURL: String { return L10n.tr("Localizable", "serverURL") } - /// Settings - internal static var settings: String { return L10n.tr("Localizable", "settings") } - /// Show Cast & Crew - internal static var showCastAndCrew: String { return L10n.tr("Localizable", "showCastAndCrew") } - /// Show Chapters Info In Bottom Overlay - internal static var showChaptersInfoInBottomOverlay: String { return L10n.tr("Localizable", "showChaptersInfoInBottomOverlay") } - /// Flatten Library Items - internal static var showFlattenView: String { return L10n.tr("Localizable", "showFlattenView") } - /// Show Missing Episodes - internal static var showMissingEpisodes: String { return L10n.tr("Localizable", "showMissingEpisodes") } - /// Show Missing Seasons - internal static var showMissingSeasons: String { return L10n.tr("Localizable", "showMissingSeasons") } - /// Show Poster Labels - internal static var showPosterLabels: String { return L10n.tr("Localizable", "showPosterLabels") } - /// Signed in as %@ - internal static func signedInAsWithString(_ p1: Any) -> String { - return L10n.tr("Localizable", "signedInAsWithString", String(describing: p1)) - } - /// Sign In - internal static var signIn: String { return L10n.tr("Localizable", "signIn") } - /// Sign in to get started - internal static var signInGetStarted: String { return L10n.tr("Localizable", "signInGetStarted") } - /// Sign In to %s - internal static func signInToServer(_ p1: UnsafePointer) -> String { - return L10n.tr("Localizable", "signInToServer", p1) - } - /// Smaller - internal static var smaller: String { return L10n.tr("Localizable", "smaller") } - /// Smallest - internal static var smallest: String { return L10n.tr("Localizable", "smallest") } - /// Sort by - internal static var sortBy: String { return L10n.tr("Localizable", "sortBy") } - /// Source Code - internal static var sourceCode: String { return L10n.tr("Localizable", "sourceCode") } - /// STUDIO - internal static var studio: String { return L10n.tr("Localizable", "studio") } - /// Studios - internal static var studios: String { return L10n.tr("Localizable", "studios") } - /// Subtitle Font - internal static var subtitleFont: String { return L10n.tr("Localizable", "subtitleFont") } - /// Subtitles - internal static var subtitles: String { return L10n.tr("Localizable", "subtitles") } - /// Subtitle Size - internal static var subtitleSize: String { return L10n.tr("Localizable", "subtitleSize") } - /// Suggestions - internal static var suggestions: String { return L10n.tr("Localizable", "suggestions") } - /// Switch User - internal static var switchUser: String { return L10n.tr("Localizable", "switchUser") } - /// System - internal static var system: String { return L10n.tr("Localizable", "system") } - /// System Control Gestures Enabled - internal static var systemControlGesturesEnabled: String { return L10n.tr("Localizable", "systemControlGesturesEnabled") } - /// Tags - internal static var tags: String { return L10n.tr("Localizable", "tags") } - /// Too Many Redirects - internal static var tooManyRedirects: String { return L10n.tr("Localizable", "tooManyRedirects") } - /// Try again - internal static var tryAgain: String { return L10n.tr("Localizable", "tryAgain") } - /// TV Shows - internal static var tvShows: String { return L10n.tr("Localizable", "tvShows") } - /// Unable to connect to server - internal static var unableToConnectServer: String { return L10n.tr("Localizable", "unableToConnectServer") } - /// Unable to find host - internal static var unableToFindHost: String { return L10n.tr("Localizable", "unableToFindHost") } - /// Unaired - internal static var unaired: String { return L10n.tr("Localizable", "unaired") } - /// Unauthorized - internal static var unauthorized: String { return L10n.tr("Localizable", "unauthorized") } - /// Unauthorized user - internal static var unauthorizedUser: String { return L10n.tr("Localizable", "unauthorizedUser") } - /// Unknown - internal static var unknown: String { return L10n.tr("Localizable", "unknown") } - /// Unknown Error - internal static var unknownError: String { return L10n.tr("Localizable", "unknownError") } - /// URL - internal static var url: String { return L10n.tr("Localizable", "url") } - /// User - internal static var user: String { return L10n.tr("Localizable", "user") } - /// User %s is already signed in - internal static func userAlreadySignedIn(_ p1: UnsafePointer) -> String { - return L10n.tr("Localizable", "userAlreadySignedIn", p1) - } - /// Username - internal static var username: String { return L10n.tr("Localizable", "username") } - /// Version - internal static var version: String { return L10n.tr("Localizable", "version") } - /// Video Player - internal static var videoPlayer: String { return L10n.tr("Localizable", "videoPlayer") } - /// Who's watching? - internal static var whosWatching: String { return L10n.tr("Localizable", "WhosWatching") } - /// WIP - internal static var wip: String { return L10n.tr("Localizable", "wip") } - /// Your Favorites - internal static var yourFavorites: String { return L10n.tr("Localizable", "yourFavorites") } -} -// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length -// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces - -// MARK: - Implementation Details - -extension L10n { - private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = TranslationService.shared.lookupTranslation(forKey:inTable:)(key, table) - return String(format: format, locale: Locale.current, arguments: args) - } -} diff --git a/Shared/Objects/ItemViewType.swift b/Shared/Objects/ItemViewType.swift new file mode 100644 index 00000000..c94a67c4 --- /dev/null +++ b/Shared/Objects/ItemViewType.swift @@ -0,0 +1,27 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation + +enum ItemViewType: String, CaseIterable, Defaults.Serializable { + case compactPoster + case compactLogo + case cinematic + + var label: String { + switch self { + case .compactPoster: + return L10n.compactPoster + case .compactLogo: + return L10n.compactLogo + case .cinematic: + return L10n.cinematic + } + } +} diff --git a/Shared/Objects/OverlaySliderColor.swift b/Shared/Objects/OverlaySliderColor.swift deleted file mode 100644 index 9200b5c5..00000000 --- a/Shared/Objects/OverlaySliderColor.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import UIKit - -enum OverlaySliderColor: String, CaseIterable, DefaultsSerializable { - case white - case jellyfinPurple - - var displayLabel: String { - switch self { - case .white: - return "White" - case .jellyfinPurple: - return "Jellyfin Purple" - } - } -} diff --git a/Shared/UIKit/PanDirectionGestureRecognizer.swift b/Shared/Objects/PanDirectionGestureRecognizer.swift similarity index 100% rename from Shared/UIKit/PanDirectionGestureRecognizer.swift rename to Shared/Objects/PanDirectionGestureRecognizer.swift diff --git a/Shared/Objects/PortraitImageStackable.swift b/Shared/Objects/PortraitImageStackable.swift deleted file mode 100644 index 1245e1ef..00000000 --- a/Shared/Objects/PortraitImageStackable.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Foundation - -public protocol PortraitImageStackable { - func imageURLConstructor(maxWidth: Int) -> URL - var title: String { get } - var subtitle: String? { get } - var blurHash: String { get } - var failureInitials: String { get } - var portraitImageID: String { get } - var showTitle: Bool { get } -} diff --git a/Shared/Objects/Poster.swift b/Shared/Objects/Poster.swift new file mode 100644 index 00000000..d041a4ec --- /dev/null +++ b/Shared/Objects/Poster.swift @@ -0,0 +1,32 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation +import SwiftUI + +protocol Poster: Hashable { + var title: String { get } + var subtitle: String? { get } + var showTitle: Bool { get } +} + +extension Poster { + func hash(into hasher: inout Hasher) { + hasher.combine(title) + hasher.combine(subtitle) + } +} + +protocol PortraitPoster: Poster { + func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource +} + +protocol LandscapePoster: Poster { + func landscapePosterImageSources(maxWidth: CGFloat) -> [ImageSource] +} diff --git a/Shared/Objects/PosterSize.swift b/Shared/Objects/PosterSize.swift deleted file mode 100644 index b4b9fd17..00000000 --- a/Shared/Objects/PosterSize.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Foundation - -enum PosterSize { - case small - case normal -} diff --git a/Shared/Singleton/BackgroundManager.swift b/Shared/Singleton/BackgroundManager.swift deleted file mode 100644 index 817e925b..00000000 --- a/Shared/Singleton/BackgroundManager.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Foundation - -final class BackgroundManager { - static let current = BackgroundManager() - fileprivate(set) var backgroundURL: URL? - fileprivate(set) var blurhash: String = "001fC^" - - init() { - backgroundURL = nil - } - - func setBackground(to: URL, hash: String) { - self.backgroundURL = to - self.blurhash = hash - - let nc = NotificationCenter.default - nc.post(name: Notification.Name("backgroundDidChange"), object: nil) - } - - func clearBackground() { - self.backgroundURL = nil - self.blurhash = "001fC^" - - let nc = NotificationCenter.default - nc.post(name: Notification.Name("backgroundDidChange"), object: nil) - } -} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift new file mode 100644 index 00000000..c346679e --- /dev/null +++ b/Shared/Strings/Strings.swift @@ -0,0 +1,488 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +import Foundation + +// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references + +// MARK: - Strings + +// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces +internal enum L10n { + /// About + internal static let about = L10n.tr("Localizable", "about", fallback: #"About"#) + /// Accessibility + internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: #"Accessibility"#) + /// Add URL + internal static let addURL = L10n.tr("Localizable", "addURL", fallback: #"Add URL"#) + /// Airs %s + internal static func airWithDate(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "airWithDate", p1, fallback: #"Airs %s"#) + } + /// All Genres + internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: #"All Genres"#) + /// All Media + internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: #"All Media"#) + /// Appearance + internal static let appearance = L10n.tr("Localizable", "appearance", fallback: #"Appearance"#) + /// Apply + internal static let apply = L10n.tr("Localizable", "apply", fallback: #"Apply"#) + /// Audio + internal static let audio = L10n.tr("Localizable", "audio", fallback: #"Audio"#) + /// Audio & Captions + internal static let audioAndCaptions = L10n.tr("Localizable", "audioAndCaptions", fallback: #"Audio & Captions"#) + /// Audio Track + internal static let audioTrack = L10n.tr("Localizable", "audioTrack", fallback: #"Audio Track"#) + /// Authorize + internal static let authorize = L10n.tr("Localizable", "authorize", fallback: #"Authorize"#) + /// Auto Play + internal static let autoPlay = L10n.tr("Localizable", "autoPlay", fallback: #"Auto Play"#) + /// Back + internal static let back = L10n.tr("Localizable", "back", fallback: #"Back"#) + /// Cancel + internal static let cancel = L10n.tr("Localizable", "cancel", fallback: #"Cancel"#) + /// Cannot connect to host + internal static let cannotConnectToHost = L10n.tr("Localizable", "cannotConnectToHost", fallback: #"Cannot connect to host"#) + /// CAST + internal static let cast = L10n.tr("Localizable", "cast", fallback: #"CAST"#) + /// Cast & Crew + internal static let castAndCrew = L10n.tr("Localizable", "castAndCrew", fallback: #"Cast & Crew"#) + /// Change Server + internal static let changeServer = L10n.tr("Localizable", "changeServer", fallback: #"Change Server"#) + /// Channels + internal static let channels = L10n.tr("Localizable", "channels", fallback: #"Channels"#) + /// Chapters + internal static let chapters = L10n.tr("Localizable", "chapters", fallback: #"Chapters"#) + /// Cinematic + internal static let cinematic = L10n.tr("Localizable", "cinematic", fallback: #"Cinematic"#) + /// Cinematic Views + internal static let cinematicViews = L10n.tr("Localizable", "cinematicViews", fallback: #"Cinematic Views"#) + /// Close + internal static let close = L10n.tr("Localizable", "close", fallback: #"Close"#) + /// Closed Captions + internal static let closedCaptions = L10n.tr("Localizable", "closedCaptions", fallback: #"Closed Captions"#) + /// Compact + internal static let compact = L10n.tr("Localizable", "compact", fallback: #"Compact"#) + /// Compact Logo + internal static let compactLogo = L10n.tr("Localizable", "compactLogo", fallback: #"Compact Logo"#) + /// Compact Poster + internal static let compactPoster = L10n.tr("Localizable", "compactPoster", fallback: #"Compact Poster"#) + /// Confirm Close + internal static let confirmClose = L10n.tr("Localizable", "confirmClose", fallback: #"Confirm Close"#) + /// Connect + internal static let connect = L10n.tr("Localizable", "connect", fallback: #"Connect"#) + /// Connect Manually + internal static let connectManually = L10n.tr("Localizable", "connectManually", fallback: #"Connect Manually"#) + /// Connect to Jellyfin + internal static let connectToJellyfin = L10n.tr("Localizable", "connectToJellyfin", fallback: #"Connect to Jellyfin"#) + /// Connect to a Jellyfin server + internal static let connectToJellyfinServer = L10n.tr("Localizable", "connectToJellyfinServer", fallback: #"Connect to a Jellyfin server"#) + /// Connect to a Jellyfin server to get started + internal static let connectToJellyfinServerStart = L10n.tr("Localizable", "connectToJellyfinServerStart", fallback: #"Connect to a Jellyfin server to get started"#) + /// Connect to Server + internal static let connectToServer = L10n.tr("Localizable", "connectToServer", fallback: #"Connect to Server"#) + /// Containers + internal static let containers = L10n.tr("Localizable", "containers", fallback: #"Containers"#) + /// Continue + internal static let `continue` = L10n.tr("Localizable", "continue", fallback: #"Continue"#) + /// Continue Watching + internal static let continueWatching = L10n.tr("Localizable", "continueWatching", fallback: #"Continue Watching"#) + /// Current Position + internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: #"Current Position"#) + /// Customize + internal static let customize = L10n.tr("Localizable", "customize", fallback: #"Customize"#) + /// Dark + internal static let dark = L10n.tr("Localizable", "dark", fallback: #"Dark"#) + /// Default Scheme + internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: #"Default Scheme"#) + /// DIRECTOR + internal static let director = L10n.tr("Localizable", "director", fallback: #"DIRECTOR"#) + /// Discovered Servers + internal static let discoveredServers = L10n.tr("Localizable", "discoveredServers", fallback: #"Discovered Servers"#) + /// Display order + internal static let displayOrder = L10n.tr("Localizable", "displayOrder", fallback: #"Display order"#) + /// Edit Jump Lengths + internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: #"Edit Jump Lengths"#) + /// Empty Next Up + internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: #"Empty Next Up"#) + /// Episode %2$@ + internal static func episodeNumber(_ p1: Any) -> String { + return L10n.tr("Localizable", "episodeNumber", String(describing: p1), fallback: #"Episode %2$@"#) + } + /// Episodes + internal static let episodes = L10n.tr("Localizable", "episodes", fallback: #"Episodes"#) + /// Error + internal static let error = L10n.tr("Localizable", "error", fallback: #"Error"#) + /// Existing Server + internal static let existingServer = L10n.tr("Localizable", "existingServer", fallback: #"Existing Server"#) + /// Existing User + internal static let existingUser = L10n.tr("Localizable", "existingUser", fallback: #"Existing User"#) + /// Experimental + internal static let experimental = L10n.tr("Localizable", "experimental", fallback: #"Experimental"#) + /// Favorites + internal static let favorites = L10n.tr("Localizable", "favorites", fallback: #"Favorites"#) + /// File + internal static let file = L10n.tr("Localizable", "file", fallback: #"File"#) + /// Filter Results + internal static let filterResults = L10n.tr("Localizable", "filterResults", fallback: #"Filter Results"#) + /// Filters + internal static let filters = L10n.tr("Localizable", "filters", fallback: #"Filters"#) + /// Genres + internal static let genres = L10n.tr("Localizable", "genres", fallback: #"Genres"#) + /// Home + internal static let home = L10n.tr("Localizable", "home", fallback: #"Home"#) + /// Information + internal static let information = L10n.tr("Localizable", "information", fallback: #"Information"#) + /// Items + internal static let items = L10n.tr("Localizable", "items", fallback: #"Items"#) + /// Jump Backward + internal static let jumpBackward = L10n.tr("Localizable", "jumpBackward", fallback: #"Jump Backward"#) + /// Jump Backward Length + internal static let jumpBackwardLength = L10n.tr("Localizable", "jumpBackwardLength", fallback: #"Jump Backward Length"#) + /// Jump Forward + internal static let jumpForward = L10n.tr("Localizable", "jumpForward", fallback: #"Jump Forward"#) + /// Jump Forward Length + internal static let jumpForwardLength = L10n.tr("Localizable", "jumpForwardLength", fallback: #"Jump Forward Length"#) + /// Jump Gestures Enabled + internal static let jumpGesturesEnabled = L10n.tr("Localizable", "jumpGesturesEnabled", fallback: #"Jump Gestures Enabled"#) + /// %s seconds + internal static func jumpLengthSeconds(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "jumpLengthSeconds", p1, fallback: #"%s seconds"#) + } + /// Larger + internal static let larger = L10n.tr("Localizable", "larger", fallback: #"Larger"#) + /// Largest + internal static let largest = L10n.tr("Localizable", "largest", fallback: #"Largest"#) + /// Latest %@ + internal static func latestWithString(_ p1: Any) -> String { + return L10n.tr("Localizable", "latestWithString", String(describing: p1), fallback: #"Latest %@"#) + } + /// Library + internal static let library = L10n.tr("Localizable", "library", fallback: #"Library"#) + /// Light + internal static let light = L10n.tr("Localizable", "light", fallback: #"Light"#) + /// Loading + internal static let loading = L10n.tr("Localizable", "loading", fallback: #"Loading"#) + /// Local Servers + internal static let localServers = L10n.tr("Localizable", "localServers", fallback: #"Local Servers"#) + /// Login + internal static let login = L10n.tr("Localizable", "login", fallback: #"Login"#) + /// Login to %@ + internal static func loginToWithString(_ p1: Any) -> String { + return L10n.tr("Localizable", "loginToWithString", String(describing: p1), fallback: #"Login to %@"#) + } + /// Media + internal static let media = L10n.tr("Localizable", "media", fallback: #"Media"#) + /// Missing + internal static let missing = L10n.tr("Localizable", "missing", fallback: #"Missing"#) + /// Missing Items + internal static let missingItems = L10n.tr("Localizable", "missingItems", fallback: #"Missing Items"#) + /// More Like This + internal static let moreLikeThis = L10n.tr("Localizable", "moreLikeThis", fallback: #"More Like This"#) + /// Movies + internal static let movies = L10n.tr("Localizable", "movies", fallback: #"Movies"#) + /// %d users + internal static func multipleUsers(_ p1: Int) -> String { + return L10n.tr("Localizable", "multipleUsers", p1, fallback: #"%d users"#) + } + /// Name + internal static let name = L10n.tr("Localizable", "name", fallback: #"Name"#) + /// Networking + internal static let networking = L10n.tr("Localizable", "networking", fallback: #"Networking"#) + /// Network timed out + internal static let networkTimedOut = L10n.tr("Localizable", "networkTimedOut", fallback: #"Network timed out"#) + /// Next + internal static let next = L10n.tr("Localizable", "next", fallback: #"Next"#) + /// Next Item + internal static let nextItem = L10n.tr("Localizable", "nextItem", fallback: #"Next Item"#) + /// Next Up + internal static let nextUp = L10n.tr("Localizable", "nextUp", fallback: #"Next Up"#) + /// No Cast devices found.. + internal static let noCastdevicesfound = L10n.tr("Localizable", "noCastdevicesfound", fallback: #"No Cast devices found.."#) + /// No Codec + internal static let noCodec = L10n.tr("Localizable", "noCodec", fallback: #"No Codec"#) + /// No episodes available + internal static let noEpisodesAvailable = L10n.tr("Localizable", "noEpisodesAvailable", fallback: #"No episodes available"#) + /// No local servers found + internal static let noLocalServersFound = L10n.tr("Localizable", "noLocalServersFound", fallback: #"No local servers found"#) + /// None + internal static let `none` = L10n.tr("Localizable", "none", fallback: #"None"#) + /// No overview available + internal static let noOverviewAvailable = L10n.tr("Localizable", "noOverviewAvailable", fallback: #"No overview available"#) + /// No public Users + internal static let noPublicUsers = L10n.tr("Localizable", "noPublicUsers", fallback: #"No public Users"#) + /// No results. + internal static let noResults = L10n.tr("Localizable", "noResults", fallback: #"No results."#) + /// Normal + internal static let normal = L10n.tr("Localizable", "normal", fallback: #"Normal"#) + /// N/A + internal static let notAvailableSlash = L10n.tr("Localizable", "notAvailableSlash", fallback: #"N/A"#) + /// Type: %@ not implemented yet :( + internal static func notImplementedYetWithType(_ p1: Any) -> String { + return L10n.tr("Localizable", "notImplementedYetWithType", String(describing: p1), fallback: #"Type: %@ not implemented yet :("#) + } + /// No title + internal static let noTitle = L10n.tr("Localizable", "noTitle", fallback: #"No title"#) + /// Ok + internal static let ok = L10n.tr("Localizable", "ok", fallback: #"Ok"#) + /// 1 user + internal static let oneUser = L10n.tr("Localizable", "oneUser", fallback: #"1 user"#) + /// Operating System + internal static let operatingSystem = L10n.tr("Localizable", "operatingSystem", fallback: #"Operating System"#) + /// Other + internal static let other = L10n.tr("Localizable", "other", fallback: #"Other"#) + /// Other User + internal static let otherUser = L10n.tr("Localizable", "otherUser", fallback: #"Other User"#) + /// Overlay + internal static let overlay = L10n.tr("Localizable", "overlay", fallback: #"Overlay"#) + /// Overlay Type + internal static let overlayType = L10n.tr("Localizable", "overlayType", fallback: #"Overlay Type"#) + /// Overview + internal static let overview = L10n.tr("Localizable", "overview", fallback: #"Overview"#) + /// Page %1$@ of %2$@ + internal static func pageOfWithNumbers(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "pageOfWithNumbers", String(describing: p1), String(describing: p2), fallback: #"Page %1$@ of %2$@"#) + } + /// Password + internal static let password = L10n.tr("Localizable", "password", fallback: #"Password"#) + /// Play + internal static let play = L10n.tr("Localizable", "play", fallback: #"Play"#) + /// Play / Pause + internal static let playAndPause = L10n.tr("Localizable", "playAndPause", fallback: #"Play / Pause"#) + /// Playback settings + internal static let playbackSettings = L10n.tr("Localizable", "playbackSettings", fallback: #"Playback settings"#) + /// Playback Speed + internal static let playbackSpeed = L10n.tr("Localizable", "playbackSpeed", fallback: #"Playback Speed"#) + /// Player Gestures Lock Gesture Enabled + internal static let playerGesturesLockGestureEnabled = L10n.tr("Localizable", "playerGesturesLockGestureEnabled", fallback: #"Player Gestures Lock Gesture Enabled"#) + /// Play From Beginning + internal static let playFromBeginning = L10n.tr("Localizable", "playFromBeginning", fallback: #"Play From Beginning"#) + /// Play Next + internal static let playNext = L10n.tr("Localizable", "playNext", fallback: #"Play Next"#) + /// Play Next Item + internal static let playNextItem = L10n.tr("Localizable", "playNextItem", fallback: #"Play Next Item"#) + /// Play Previous Item + internal static let playPreviousItem = L10n.tr("Localizable", "playPreviousItem", fallback: #"Play Previous Item"#) + /// Present + internal static let present = L10n.tr("Localizable", "present", fallback: #"Present"#) + /// Press Down for Menu + internal static let pressDownForMenu = L10n.tr("Localizable", "pressDownForMenu", fallback: #"Press Down for Menu"#) + /// Previous Item + internal static let previousItem = L10n.tr("Localizable", "previousItem", fallback: #"Previous Item"#) + /// Programs + internal static let programs = L10n.tr("Localizable", "programs", fallback: #"Programs"#) + /// Public Users + internal static let publicUsers = L10n.tr("Localizable", "publicUsers", fallback: #"Public Users"#) + /// Quick Connect + internal static let quickConnect = L10n.tr("Localizable", "quickConnect", fallback: #"Quick Connect"#) + /// Quick Connect code + internal static let quickConnectCode = L10n.tr("Localizable", "quickConnectCode", fallback: #"Quick Connect code"#) + /// Invalid Quick Connect code + internal static let quickConnectInvalidError = L10n.tr("Localizable", "quickConnectInvalidError", fallback: #"Invalid Quick Connect code"#) + /// Note: Quick Connect not enabled + internal static let quickConnectNotEnabled = L10n.tr("Localizable", "quickConnectNotEnabled", fallback: #"Note: Quick Connect not enabled"#) + /// 1. Open the Jellyfin app on your phone or web browser and sign in with your account + internal static let quickConnectStep1 = L10n.tr("Localizable", "quickConnectStep1", fallback: #"1. Open the Jellyfin app on your phone or web browser and sign in with your account"#) + /// 2. Open the user menu and go to the Quick Connect page + internal static let quickConnectStep2 = L10n.tr("Localizable", "quickConnectStep2", fallback: #"2. Open the user menu and go to the Quick Connect page"#) + /// 3. Enter the following code: + internal static let quickConnectStep3 = L10n.tr("Localizable", "quickConnectStep3", fallback: #"3. Enter the following code:"#) + /// Authorizing Quick Connect successful. Please continue on your other device. + internal static let quickConnectSuccessMessage = L10n.tr("Localizable", "quickConnectSuccessMessage", fallback: #"Authorizing Quick Connect successful. Please continue on your other device."#) + /// Rated + internal static let rated = L10n.tr("Localizable", "rated", fallback: #"Rated"#) + /// Recently Added + internal static let recentlyAdded = L10n.tr("Localizable", "recentlyAdded", fallback: #"Recently Added"#) + /// Recommended + internal static let recommended = L10n.tr("Localizable", "recommended", fallback: #"Recommended"#) + /// Refresh + internal static let refresh = L10n.tr("Localizable", "refresh", fallback: #"Refresh"#) + /// Regular + internal static let regular = L10n.tr("Localizable", "regular", fallback: #"Regular"#) + /// Released + internal static let released = L10n.tr("Localizable", "released", fallback: #"Released"#) + /// Remaining Time + internal static let remainingTime = L10n.tr("Localizable", "remainingTime", fallback: #"Remaining Time"#) + /// Remove + internal static let remove = L10n.tr("Localizable", "remove", fallback: #"Remove"#) + /// Remove All Users + internal static let removeAllUsers = L10n.tr("Localizable", "removeAllUsers", fallback: #"Remove All Users"#) + /// Remove From Resume + internal static let removeFromResume = L10n.tr("Localizable", "removeFromResume", fallback: #"Remove From Resume"#) + /// Report an Issue + internal static let reportIssue = L10n.tr("Localizable", "reportIssue", fallback: #"Report an Issue"#) + /// Request a Feature + internal static let requestFeature = L10n.tr("Localizable", "requestFeature", fallback: #"Request a Feature"#) + /// Reset + internal static let reset = L10n.tr("Localizable", "reset", fallback: #"Reset"#) + /// Reset App Settings + internal static let resetAppSettings = L10n.tr("Localizable", "resetAppSettings", fallback: #"Reset App Settings"#) + /// Reset User Settings + internal static let resetUserSettings = L10n.tr("Localizable", "resetUserSettings", fallback: #"Reset User Settings"#) + /// Resume 5 Second Offset + internal static let resume5SecondOffset = L10n.tr("Localizable", "resume5SecondOffset", fallback: #"Resume 5 Second Offset"#) + /// Retry + internal static let retry = L10n.tr("Localizable", "retry", fallback: #"Retry"#) + /// Runtime + internal static let runtime = L10n.tr("Localizable", "runtime", fallback: #"Runtime"#) + /// Search + internal static let search = L10n.tr("Localizable", "search", fallback: #"Search"#) + /// Search… + internal static let searchDots = L10n.tr("Localizable", "searchDots", fallback: #"Search…"#) + /// Searching… + internal static let searchingDots = L10n.tr("Localizable", "searchingDots", fallback: #"Searching…"#) + /// Season + internal static let season = L10n.tr("Localizable", "season", fallback: #"Season"#) + /// S%1$@:E%2$@ + internal static func seasonAndEpisode(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "seasonAndEpisode", String(describing: p1), String(describing: p2), fallback: #"S%1$@:E%2$@"#) + } + /// Seasons + internal static let seasons = L10n.tr("Localizable", "seasons", fallback: #"Seasons"#) + /// See All + internal static let seeAll = L10n.tr("Localizable", "seeAll", fallback: #"See All"#) + /// Seek Slide Gesture Enabled + internal static let seekSlideGestureEnabled = L10n.tr("Localizable", "seekSlideGestureEnabled", fallback: #"Seek Slide Gesture Enabled"#) + /// See More + internal static let seeMore = L10n.tr("Localizable", "seeMore", fallback: #"See More"#) + /// Select Cast Destination + internal static let selectCastDestination = L10n.tr("Localizable", "selectCastDestination", fallback: #"Select Cast Destination"#) + /// Series + internal static let series = L10n.tr("Localizable", "series", fallback: #"Series"#) + /// Server + internal static let server = L10n.tr("Localizable", "server", fallback: #"Server"#) + /// Server %s is already connected + internal static func serverAlreadyConnected(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "serverAlreadyConnected", p1, fallback: #"Server %s is already connected"#) + } + /// Server %s already exists. Add new URL? + internal static func serverAlreadyExistsPrompt(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "serverAlreadyExistsPrompt", p1, fallback: #"Server %s already exists. Add new URL?"#) + } + /// Server Details + internal static let serverDetails = L10n.tr("Localizable", "serverDetails", fallback: #"Server Details"#) + /// Server Information + internal static let serverInformation = L10n.tr("Localizable", "serverInformation", fallback: #"Server Information"#) + /// Servers + internal static let servers = L10n.tr("Localizable", "servers", fallback: #"Servers"#) + /// Server URL + internal static let serverURL = L10n.tr("Localizable", "serverURL", fallback: #"Server URL"#) + /// Settings + internal static let settings = L10n.tr("Localizable", "settings", fallback: #"Settings"#) + /// Show Cast & Crew + internal static let showCastAndCrew = L10n.tr("Localizable", "showCastAndCrew", fallback: #"Show Cast & Crew"#) + /// Show Chapters Info In Bottom Overlay + internal static let showChaptersInfoInBottomOverlay = L10n.tr("Localizable", "showChaptersInfoInBottomOverlay", fallback: #"Show Chapters Info In Bottom Overlay"#) + /// Flatten Library Items + internal static let showFlattenView = L10n.tr("Localizable", "showFlattenView", fallback: #"Flatten Library Items"#) + /// Show Missing Episodes + internal static let showMissingEpisodes = L10n.tr("Localizable", "showMissingEpisodes", fallback: #"Show Missing Episodes"#) + /// Show Missing Seasons + internal static let showMissingSeasons = L10n.tr("Localizable", "showMissingSeasons", fallback: #"Show Missing Seasons"#) + /// Show Poster Labels + internal static let showPosterLabels = L10n.tr("Localizable", "showPosterLabels", fallback: #"Show Poster Labels"#) + /// Signed in as %@ + internal static func signedInAsWithString(_ p1: Any) -> String { + return L10n.tr("Localizable", "signedInAsWithString", String(describing: p1), fallback: #"Signed in as %@"#) + } + /// Sign In + internal static let signIn = L10n.tr("Localizable", "signIn", fallback: #"Sign In"#) + /// Sign in to get started + internal static let signInGetStarted = L10n.tr("Localizable", "signInGetStarted", fallback: #"Sign in to get started"#) + /// Sign In to %s + internal static func signInToServer(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "signInToServer", p1, fallback: #"Sign In to %s"#) + } + /// Smaller + internal static let smaller = L10n.tr("Localizable", "smaller", fallback: #"Smaller"#) + /// Smallest + internal static let smallest = L10n.tr("Localizable", "smallest", fallback: #"Smallest"#) + /// Sort by + internal static let sortBy = L10n.tr("Localizable", "sortBy", fallback: #"Sort by"#) + /// Source Code + internal static let sourceCode = L10n.tr("Localizable", "sourceCode", fallback: #"Source Code"#) + /// STUDIO + internal static let studio = L10n.tr("Localizable", "studio", fallback: #"STUDIO"#) + /// Studios + internal static let studios = L10n.tr("Localizable", "studios", fallback: #"Studios"#) + /// Subtitle Font + internal static let subtitleFont = L10n.tr("Localizable", "subtitleFont", fallback: #"Subtitle Font"#) + /// Subtitles + internal static let subtitles = L10n.tr("Localizable", "subtitles", fallback: #"Subtitles"#) + /// Subtitle Size + internal static let subtitleSize = L10n.tr("Localizable", "subtitleSize", fallback: #"Subtitle Size"#) + /// Suggestions + internal static let suggestions = L10n.tr("Localizable", "suggestions", fallback: #"Suggestions"#) + /// Switch User + internal static let switchUser = L10n.tr("Localizable", "switchUser", fallback: #"Switch User"#) + /// System + internal static let system = L10n.tr("Localizable", "system", fallback: #"System"#) + /// System Control Gestures Enabled + internal static let systemControlGesturesEnabled = L10n.tr("Localizable", "systemControlGesturesEnabled", fallback: #"System Control Gestures Enabled"#) + /// Tags + internal static let tags = L10n.tr("Localizable", "tags", fallback: #"Tags"#) + /// Too Many Redirects + internal static let tooManyRedirects = L10n.tr("Localizable", "tooManyRedirects", fallback: #"Too Many Redirects"#) + /// Try again + internal static let tryAgain = L10n.tr("Localizable", "tryAgain", fallback: #"Try again"#) + /// TV Shows + internal static let tvShows = L10n.tr("Localizable", "tvShows", fallback: #"TV Shows"#) + /// Unable to connect to server + internal static let unableToConnectServer = L10n.tr("Localizable", "unableToConnectServer", fallback: #"Unable to connect to server"#) + /// Unable to find host + internal static let unableToFindHost = L10n.tr("Localizable", "unableToFindHost", fallback: #"Unable to find host"#) + /// Unaired + internal static let unaired = L10n.tr("Localizable", "unaired", fallback: #"Unaired"#) + /// Unauthorized + internal static let unauthorized = L10n.tr("Localizable", "unauthorized", fallback: #"Unauthorized"#) + /// Unauthorized user + internal static let unauthorizedUser = L10n.tr("Localizable", "unauthorizedUser", fallback: #"Unauthorized user"#) + /// Unknown + internal static let unknown = L10n.tr("Localizable", "unknown", fallback: #"Unknown"#) + /// Unknown Error + internal static let unknownError = L10n.tr("Localizable", "unknownError", fallback: #"Unknown Error"#) + /// URL + internal static let url = L10n.tr("Localizable", "url", fallback: #"URL"#) + /// User + internal static let user = L10n.tr("Localizable", "user", fallback: #"User"#) + /// User %s is already signed in + internal static func userAlreadySignedIn(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "userAlreadySignedIn", p1, fallback: #"User %s is already signed in"#) + } + /// Username + internal static let username = L10n.tr("Localizable", "username", fallback: #"Username"#) + /// Version + internal static let version = L10n.tr("Localizable", "version", fallback: #"Version"#) + /// Video Player + internal static let videoPlayer = L10n.tr("Localizable", "videoPlayer", fallback: #"Video Player"#) + /// Who's watching? + internal static let whosWatching = L10n.tr("Localizable", "WhosWatching", fallback: #"Who's watching?"#) + /// WIP + internal static let wip = L10n.tr("Localizable", "wip", fallback: #"WIP"#) + /// Your Favorites + internal static let yourFavorites = L10n.tr("Localizable", "yourFavorites", fallback: #"Your Favorites"#) +} +// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces + +// MARK: - Implementation Details + +extension L10n { + private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { + let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) + return String(format: format, locale: Locale.current, arguments: args) + } +} + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index c6a4854b..be125f6f 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -10,6 +10,8 @@ import Defaults import Foundation import UIKit +// TODO: Refactor... + extension SwiftfinStore { enum Defaults { static let generalSuite: UserDefaults = .init(suiteName: "swiftfinstore-general-defaults")! @@ -39,6 +41,7 @@ extension Defaults.Keys { static let showPosterLabels = Key("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let showCastAndCrew = Key("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let showFlattenView = Key("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let itemViewType = Key("itemViewType", default: .compactLogo, suite: SwiftfinStore.Defaults.generalSuite) // Video player / overlay settings static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) @@ -116,5 +119,4 @@ extension Defaults.Keys { // tvos specific static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let confirmClose = Key("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let tvOSCinematicViews = Key("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite) } diff --git a/Shared/ViewModels/EpisodesRowManager.swift b/Shared/ViewModels/EpisodesRowManager.swift index 711988cc..684064b0 100644 --- a/Shared/ViewModels/EpisodesRowManager.swift +++ b/Shared/ViewModels/EpisodesRowManager.swift @@ -14,9 +14,11 @@ protocol EpisodesRowManager: ViewModel { var item: BaseItemDto { get } var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set } var selectedSeason: BaseItemDto? { get set } - func retrieveSeasons() - func retrieveEpisodesForSeason(_ season: BaseItemDto) + + func getSeasons() + func getEpisodesForSeason(_ season: BaseItemDto) func select(season: BaseItemDto) + func select(seasonID: String) } extension EpisodesRowManager { @@ -25,10 +27,19 @@ extension EpisodesRowManager { Array(seasonsEpisodes.keys).sorted(by: { $0.indexNumber ?? 0 < $1.indexNumber ?? 0 }) } + var currentEpisodes: [BaseItemDto]? { + if let selectedSeason = selectedSeason { + return seasonsEpisodes[selectedSeason] + } else { + guard let firstSeason = seasonsEpisodes.keys.first else { return nil } + return seasonsEpisodes[firstSeason] + } + } + // Also retrieves the current season episodes if available - func retrieveSeasons() { + func getSeasons() { TvShowsAPI.getSeasons( - seriesId: item.seriesId ?? "", + seriesId: item.id ?? "", userId: SessionManager.main.currentLogin.user.id, isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false ) @@ -36,26 +47,21 @@ extension EpisodesRowManager { self.handleAPIRequestError(completion: completion) } receiveValue: { response in let seasons = response.items ?? [] + seasons.forEach { season in self.seasonsEpisodes[season] = [] - - if season.id == self.item.seasonId ?? "" { - self.selectedSeason = season - self.retrieveEpisodesForSeason(season) - } else if season.id == self.item.id ?? "" { - self.selectedSeason = season - self.retrieveEpisodesForSeason(season) - } } + + self.selectedSeason = seasons.first } .store(in: &cancellables) } - func retrieveEpisodesForSeason(_ season: BaseItemDto) { + func getEpisodesForSeason(_ season: BaseItemDto) { guard let seasonID = season.id else { return } TvShowsAPI.getEpisodes( - seriesId: item.seriesId ?? "", + seriesId: item.id ?? "", userId: SessionManager.main.currentLogin.user.id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seasonId: seasonID, @@ -74,7 +80,12 @@ extension EpisodesRowManager { self.selectedSeason = season if seasonsEpisodes[season]!.isEmpty { - retrieveEpisodesForSeason(season) + getEpisodesForSeason(season) } } + + func select(seasonID: String) { + guard let selectedSeason = Array(seasonsEpisodes.keys).first(where: { $0.id == seasonID }) else { return } + select(season: selectedSeason) + } } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 984ffad4..cc5a2f33 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -25,7 +25,7 @@ final class HomeViewModel: ViewModel { var libraries: [BaseItemDto] = [] // temp - var recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded]) + static let recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded]) override init() { super.init() @@ -139,7 +139,7 @@ final class HomeViewModel: ViewModel { includeItemTypes: [.movie, .series], enableImageTypes: [.primary, .backdrop, .thumb], enableUserData: true, - limit: 8 + limit: 20 ) .sink { completion in switch completion { @@ -161,7 +161,7 @@ final class HomeViewModel: ViewModel { private func refreshResumeItems() { ItemsAPI.getResumeItems( userId: SessionManager.main.currentLogin.user.id, - limit: 6, + limit: 20, fields: [ .primaryImageAspectRatio, .seriesPrimaryImage, @@ -210,7 +210,7 @@ final class HomeViewModel: ViewModel { private func refreshNextUpItems() { TvShowsAPI.getNextUp( userId: SessionManager.main.currentLogin.user.id, - limit: 6, + limit: 20, fields: [ .primaryImageAspectRatio, .seriesPrimaryImage, diff --git a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift index a56f35e9..da0e9423 100644 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift @@ -25,7 +25,7 @@ final class CollectionItemViewModel: ItemViewModel { ItemsAPI.getItems( userId: SessionManager.main.currentLogin.user.id, parentId: item.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people] + fields: ItemFields.allCases ) .trackActivity(loading) .sink { [weak self] completion in diff --git a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift index e4e379cf..a40fa7c2 100644 --- a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift @@ -11,39 +11,22 @@ import Foundation import JellyfinAPI import Stinsen -final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager { +final class EpisodeItemViewModel: ItemViewModel { @RouterObject - var itemRouter: ItemCoordinator.Router? + private var itemRouter: ItemCoordinator.Router? @Published - var series: BaseItemDto? + var playButtonText: String = "" @Published - var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] - @Published - var selectedSeason: BaseItemDto? + var mediaDetailItems: [[BaseItemDto.ItemDetail]] = [] override init(item: BaseItemDto) { super.init(item: item) - getEpisodeSeries() - retrieveSeasons() - } - - override func getItemDisplayName() -> String { - guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } - return "\(episodeLocator)\n\(item.name ?? "")" - } - - func getEpisodeSeries() { - guard let id = item.seriesId else { return } - UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] item in - self?.series = item - }) - .store(in: &cancellables) + $videoPlayerViewModels.sink(receiveValue: { newValue in + self.mediaDetailItems = self.createMediaDetailItems(viewModels: newValue) + }) + .store(in: &cancellables) } override func updateItem() { @@ -72,4 +55,30 @@ final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager { } .store(in: &cancellables) } + + private func createMediaDetailItems(viewModels: [VideoPlayerViewModel]) -> [[BaseItemDto.ItemDetail]] { + var fileMediaItems: [[BaseItemDto.ItemDetail]] = [] + + for viewModel in viewModels { + + let audioStreams = viewModel.audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } + .joined(separator: ", ") + + let subtitleStreams = viewModel.subtitleStreams + .compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } + .joined(separator: ", ") + + let currentMediaItems: [BaseItemDto.ItemDetail] = [ + .init(title: "File", content: viewModel.filename ?? "--"), + .init(title: "Audio", content: audioStreams), + .init(title: "Subtitles", content: subtitleStreams), + ] + + fileMediaItems.append(currentMediaItems) + } + + // print(fileMediaItems) + + return fileMediaItems + } } diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index 2f73c129..954eb19c 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -31,15 +31,15 @@ class ItemViewModel: ViewModel { @Published var isFavorited = false @Published - var informationItems: [BaseItemDto.ItemDetail] - @Published var selectedVideoPlayerViewModel: VideoPlayerViewModel? + @Published var videoPlayerViewModels: [VideoPlayerViewModel] = [] init(item: BaseItemDto) { self.item = item + super.init() - switch item.itemType { + switch item.type { case .episode, .movie: if !item.missing && !item.unaired { self.playButtonItem = item @@ -47,17 +47,13 @@ class ItemViewModel: ViewModel { default: () } - informationItems = item.createInformationItems() - isFavorited = item.userData?.isFavorite ?? false isWatched = item.userData?.played ?? false - super.init() getSimilarItems() + refreshItemVideoPlayerViewModel(for: item) Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:))) - - refreshItemVideoPlayerViewModel(for: item) } @objc @@ -74,7 +70,7 @@ class ItemViewModel: ViewModel { } func refreshItemVideoPlayerViewModel(for item: BaseItemDto) { - guard item.itemType == .episode || item.itemType == .movie else { return } + guard item.type == .episode || item.type == .movie else { return } guard !item.missing, !item.unaired else { return } item.createVideoPlayerViewModel() @@ -104,20 +100,12 @@ class ItemViewModel: ViewModel { return L10n.play } - func getItemDisplayName() -> String { - item.name ?? "" - } - - func shouldDisplayRuntime() -> Bool { - true - } - func getSimilarItems() { LibraryAPI.getSimilarItems( itemId: item.id!, userId: SessionManager.main.currentLogin.user.id, - limit: 10, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people] + limit: 20, + fields: ItemFields.allCases ) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in @@ -128,54 +116,52 @@ class ItemViewModel: ViewModel { .store(in: &cancellables) } - func updateWatchState() { - if isWatched { - PlaystateAPI.markUnplayedItem( - userId: SessionManager.main.currentLogin.user.id, - itemId: item.id! - ) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] _ in - self?.isWatched = false - }) - .store(in: &cancellables) + func toggleWatchState() { + let current = isWatched + isWatched.toggle() + let request: AnyPublisher + + if current { + request = PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) } else { - PlaystateAPI.markPlayedItem( - userId: SessionManager.main.currentLogin.user.id, - itemId: item.id! - ) + request = PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) + } + + request .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in + switch completion { + case .failure: + self?.isWatched = !current + case .finished: () + } self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] _ in - self?.isWatched = true - }) + }, receiveValue: { _ in }) .store(in: &cancellables) - } } - func updateFavoriteState() { - if isFavorited { - UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] _ in - self?.isFavorited = false - }) - .store(in: &cancellables) + func toggleFavoriteState() { + let current = isFavorited + isFavorited.toggle() + let request: AnyPublisher + + if current { + request = UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) } else { - UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] _ in - self?.isFavorited = true - }) - .store(in: &cancellables) + request = UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) } + + request + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + switch completion { + case .failure: + self?.isFavorited = !current + case .finished: () + } + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { _ in }) + .store(in: &cancellables) } // Overridden by subclasses diff --git a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift index 777cc4f2..dd1a26fb 100644 --- a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift @@ -14,11 +14,7 @@ import Stinsen final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager { @RouterObject - var itemRouter: ItemCoordinator.Router? - @Published - var episodes: [BaseItemDto] = [] - @Published - var seriesItem: BaseItemDto? + private var itemRouter: ItemCoordinator.Router? @Published var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] @Published @@ -27,9 +23,8 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager { override init(item: BaseItemDto) { super.init(item: item) - getSeriesItem() selectedSeason = item - retrieveSeasons() +// getSeasons() requestEpisodes() } @@ -39,7 +34,7 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager { return L10n.unaired } - guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } + guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.episodeLocator else { return L10n.play } return episodeLocator } @@ -57,8 +52,7 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager { self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] response in guard let self = self else { return } - self.episodes = response.items ?? [] - LogManager.log.debug("Retrieved \(String(self.episodes.count)) episodes") + self.seasonsEpisodes[self.item] = response.items ?? [] self.setNextUpInSeason() }) @@ -87,44 +81,29 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager { LogManager.log.debug("Nextup in season \(self.item.id!) (\(self.item.name!)): \(nextUpItem.id!)") } - if self.playButtonItem == nil && !self.episodes.isEmpty { - // Fallback to the old mechanism: - // Sets the play button item to the "Next up" in the season based upon - // the watched status of episodes in the season. - // Default to the first episode of the season if all have been watched. - var firstUnwatchedSearch: BaseItemDto? - - for episode in self.episodes { - guard let played = episode.userData?.played else { continue } - if !played { - firstUnwatchedSearch = episode - break - } - } - - if let firstUnwatched = firstUnwatchedSearch { - self.playButtonItem = firstUnwatched - } else { - guard let firstEpisode = self.episodes.first else { return } - self.playButtonItem = firstEpisode - } - } + // if self.playButtonItem == nil && !self.episodes.isEmpty { + // // Fallback to the old mechanism: + // // Sets the play button item to the "Next up" in the season based upon + // // the watched status of episodes in the season. + // // Default to the first episode of the season if all have been watched. + // var firstUnwatchedSearch: BaseItemDto? +// + // for episode in self.episodes { + // guard let played = episode.userData?.played else { continue } + // if !played { + // firstUnwatchedSearch = episode + // break + // } + // } +// + // if let firstUnwatched = firstUnwatchedSearch { + // self.playButtonItem = firstUnwatched + // } else { + // guard let firstEpisode = self.episodes.first else { return } + // self.playButtonItem = firstEpisode + // } + // } }) .store(in: &cancellables) } - - private func getSeriesItem() { - guard let seriesID = item.seriesId else { return } - UserLibraryAPI.getItem( - userId: SessionManager.main.currentLogin.user.id, - itemId: seriesID - ) - .trackActivity(loading) - .sink { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - } receiveValue: { [weak self] seriesItem in - self?.seriesItem = seriesItem - } - .store(in: &cancellables) - } } diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift index e035b8a0..d197c06c 100644 --- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift @@ -11,16 +11,25 @@ import Defaults import Foundation import JellyfinAPI -final class SeriesItemViewModel: ItemViewModel { +final class SeriesItemViewModel: ItemViewModel, EpisodesRowManager { @Published - var seasons: [BaseItemDto] = [] + var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] + @Published + var selectedSeason: BaseItemDto? override init(item: BaseItemDto) { super.init(item: item) - requestSeasons() + getSeasons() + + // The server won't have both a next up item + // and a resume item at the same time, so they + // control the button first. Also fetch first available + // item, which may be overwritten by next up or resume. getNextUp() + getResumeItem() + getFirstAvailableItem() } override func playButtonText() -> String { @@ -33,20 +42,16 @@ final class SeriesItemViewModel: ItemViewModel { return L10n.missing } - guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } + guard let playButtonItem = playButtonItem, + let episodeLocator = playButtonItem.seasonEpisodeLocator else { return L10n.play } + return episodeLocator } - override func shouldDisplayRuntime() -> Bool { - false - } - private func getNextUp() { - LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))") TvShowsAPI.getNextUp( userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seriesId: self.item.id!, enableUserData: true ) @@ -56,12 +61,64 @@ final class SeriesItemViewModel: ItemViewModel { }, receiveValue: { [weak self] response in if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing { self?.playButtonItem = nextUpItem + + if let seasonID = nextUpItem.seasonId { + self?.select(seasonID: seasonID) + } } }) .store(in: &cancellables) } - private func getRunYears() -> String { + private func getResumeItem() { + ItemsAPI.getResumeItems( + userId: SessionManager.main.currentLogin.user.id, + limit: 1, + parentId: item.id + ) + .trackActivity(loading) + .sink { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + } receiveValue: { [weak self] response in + if let firstItem = response.items?.first { + self?.playButtonItem = firstItem + + if let seasonID = firstItem.seasonId { + self?.select(seasonID: seasonID) + } + } + } + .store(in: &cancellables) + } + + private func getFirstAvailableItem() { + ItemsAPI.getItemsByUserId( + userId: SessionManager.main.currentLogin.user.id, + limit: 2, + recursive: true, + sortOrder: [.ascending], + parentId: item.id, + includeItemTypes: [.episode] + ) + .trackActivity(loading) + .sink { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + } receiveValue: { [weak self] response in + if let firstItem = response.items?.first { + if self?.playButtonItem == nil { + // If other calls finish after this, it will be overwritten + self?.playButtonItem = firstItem + + if let seasonID = firstItem.seasonId { + self?.select(seasonID: seasonID) + } + } + } + } + .store(in: &cancellables) + } + + func getRunYears() -> String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy" @@ -78,23 +135,4 @@ final class SeriesItemViewModel: ItemViewModel { return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)" } - - private func requestSeasons() { - LogManager.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))") - TvShowsAPI.getSeasons( - seriesId: item.id ?? "", - userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false, - enableUserData: true - ) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - self?.seasons = response.items ?? [] - LogManager.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons") - }) - .store(in: &cancellables) - } } diff --git a/Shared/ViewModels/LatestMediaViewModel.swift b/Shared/ViewModels/LatestMediaViewModel.swift index d2f701d2..4ad903ef 100644 --- a/Shared/ViewModels/LatestMediaViewModel.swift +++ b/Shared/ViewModels/LatestMediaViewModel.swift @@ -29,14 +29,7 @@ final class LatestMediaViewModel: ViewModel { UserLibraryAPI.getLatestMedia( userId: SessionManager.main.currentLogin.user.id, parentId: library.id ?? "", - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - ], + fields: ItemFields.allCases, includeItemTypes: [.series, .movie], enableUserData: true, limit: 12 diff --git a/Shared/ViewModels/LibraryListViewModel.swift b/Shared/ViewModels/LibraryListViewModel.swift index 95536a8f..71df2a9a 100644 --- a/Shared/ViewModels/LibraryListViewModel.swift +++ b/Shared/ViewModels/LibraryListViewModel.swift @@ -6,6 +6,7 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import Defaults import Foundation import JellyfinAPI @@ -14,8 +15,18 @@ final class LibraryListViewModel: ViewModel { @Published var libraries: [BaseItemDto] = [] + var filteredLibraries: [BaseItemDto] { + var supportedLibraries = ["movies", "tvshows", "unknown"] + + if Defaults[.Experimental.liveTVAlphaEnabled] { + supportedLibraries.append("livetv") + } + + return libraries.filter { supportedLibraries.contains($0.collectionType ?? "unknown") } + } + // temp - var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: []) + let withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: []) override init() { super.init() diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index 7d6a8caf..3e101c92 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -115,15 +115,7 @@ final class LibraryViewModel: ViewModel { searchTerm: nil, sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) }, parentId: parentID, - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - .chapters, - ], + fields: ItemFields.allCases, includeItemTypes: includeItemTypes, filters: filters.filters, sortBy: sortBy, diff --git a/Shared/ViewModels/MainTabViewModel.swift b/Shared/ViewModels/MainTabViewModel.swift deleted file mode 100644 index 4581cbf7..00000000 --- a/Shared/ViewModels/MainTabViewModel.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI - -final class MainTabViewModel: ViewModel { - @Published - var backgroundURL: URL? - @Published - var lastBackgroundURL: URL? - @Published - var backgroundBlurHash: String = "001fC^" - - override init() { - super.init() - - let nc = NotificationCenter.default - nc.addObserver(self, selector: #selector(backgroundDidChange), name: Notification.Name("backgroundDidChange"), object: nil) - } - - @objc - func backgroundDidChange() { - self.lastBackgroundURL = self.backgroundURL - self.backgroundURL = BackgroundManager.current.backgroundURL - self.backgroundBlurHash = BackgroundManager.current.blurhash - } -} diff --git a/Shared/ViewModels/MovieLibrariesViewModel.swift b/Shared/ViewModels/MovieLibrariesViewModel.swift index 09ca6d4b..75d41c34 100644 --- a/Shared/ViewModels/MovieLibrariesViewModel.swift +++ b/Shared/ViewModels/MovieLibrariesViewModel.swift @@ -29,7 +29,7 @@ final class MovieLibrariesViewModel: ViewModel { private let columns: Int @RouterObject - var router: MovieLibrariesCoordinator.Router? + private var router: MovieLibrariesCoordinator.Router? init(columns: Int = 7) { self.columns = columns diff --git a/Shared/ViewModels/TVLibrariesViewModel.swift b/Shared/ViewModels/TVLibrariesViewModel.swift index 7109bfff..3d96ed09 100644 --- a/Shared/ViewModels/TVLibrariesViewModel.swift +++ b/Shared/ViewModels/TVLibrariesViewModel.swift @@ -29,7 +29,7 @@ final class TVLibrariesViewModel: ViewModel { private let columns: Int @RouterObject - var router: TVLibrariesCoordinator.Router? + private var router: TVLibrariesCoordinator.Router? init(columns: Int = 7) { self.columns = columns diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index 8ff1bfd7..1eb6e0fd 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -14,7 +14,7 @@ import Stinsen final class UserSignInViewModel: ViewModel { @RouterObject - var router: UserSignInCoordinator.Router? + private var Router: UserSignInCoordinator.Router? @Published var publicUsers: [UserDto] = [] diff --git a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift index 99565b82..747f50db 100644 --- a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift @@ -123,6 +123,7 @@ final class VideoPlayerViewModel: ViewModel { let directStreamURL: URL let transcodedStreamURL: URL? let hlsStreamURL: URL + let videoStream: MediaStream let audioStreams: [MediaStream] let subtitleStreams: [MediaStream] let chapters: [ChapterInfo] @@ -220,6 +221,7 @@ final class VideoPlayerViewModel: ViewModel { hlsStreamURL: URL, streamType: ServerStreamType, response: PlaybackInfoResponse, + videoStream: MediaStream, audioStreams: [MediaStream], subtitleStreams: [MediaStream], chapters: [ChapterInfo], @@ -243,6 +245,7 @@ final class VideoPlayerViewModel: ViewModel { self.hlsStreamURL = hlsStreamURL self.streamType = streamType self.response = response + self.videoStream = videoStream self.audioStreams = audioStreams self.subtitleStreams = subtitleStreams self.chapters = chapters @@ -333,7 +336,7 @@ extension VideoPlayerViewModel { extension VideoPlayerViewModel { func getAdjacentEpisodes() { - guard let seriesID = item.seriesId, item.itemType == .episode else { return } + guard let seriesID = item.seriesId, item.type == .episode else { return } TvShowsAPI.getEpisodes( seriesId: seriesID, diff --git a/Swiftfin/Components/AppIcon.swift b/Shared/Views/AppIcon.swift similarity index 100% rename from Swiftfin/Components/AppIcon.swift rename to Shared/Views/AppIcon.swift diff --git a/Shared/Views/AttributeFillView.swift b/Shared/Views/AttributeFillView.swift new file mode 100644 index 00000000..21328ce5 --- /dev/null +++ b/Shared/Views/AttributeFillView.swift @@ -0,0 +1,32 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct AttributeFillView: View { + + let text: String + + var body: some View { + Text(text) + .font(.caption) + .fontWeight(.semibold) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .hidden() + .background { + Color(UIColor.lightGray) + .cornerRadius(2) + .inverseMask( + Text(text) + .font(.caption) + .fontWeight(.semibold) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + ) + } + } +} diff --git a/Shared/Views/AttributeOutlineView.swift b/Shared/Views/AttributeOutlineView.swift new file mode 100644 index 00000000..d982b294 --- /dev/null +++ b/Shared/Views/AttributeOutlineView.swift @@ -0,0 +1,26 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct AttributeOutlineView: View { + + let text: String + + var body: some View { + Text(text) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(Color(UIColor.lightGray)) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color(UIColor.lightGray), lineWidth: 1) + ) + } +} diff --git a/Shared/Views/BlurHashView.swift b/Shared/Views/BlurHashView.swift index 65e94ca6..edc22b95 100644 --- a/Shared/Views/BlurHashView.swift +++ b/Shared/Views/BlurHashView.swift @@ -11,10 +11,16 @@ import UIKit struct BlurHashView: UIViewRepresentable { - let blurHash: String + private let blurHash: String + private let size: CGSize + + init(blurHash: String, size: CGSize = .Circle(radius: 12)) { + self.blurHash = blurHash + self.size = size + } func makeUIView(context: Context) -> UIBlurHashView { - UIBlurHashView(blurHash) + UIBlurHashView(blurHash, size: size) } func updateUIView(_ uiView: UIBlurHashView, context: Context) {} @@ -24,14 +30,14 @@ class UIBlurHashView: UIView { private let imageView: UIImageView - init(_ blurHash: String) { + init(_ blurHash: String, size: CGSize) { let imageView = UIImageView() imageView.translatesAutoresizingMaskIntoConstraints = false self.imageView = imageView super.init(frame: .zero) - computeBlurHashImageAsync(blurHash: blurHash) { [weak self] blurImage in + computeBlurHashImageAsync(blurHash: blurHash, size: size) { [weak self] blurImage in guard let self = self else { return } DispatchQueue.main.async { self.imageView.image = blurImage @@ -54,9 +60,9 @@ class UIBlurHashView: UIView { fatalError("init(coder:) has not been implemented") } - private func computeBlurHashImageAsync(blurHash: String, _ completion: @escaping (UIImage?) -> Void) { + private func computeBlurHashImageAsync(blurHash: String, size: CGSize, _ completion: @escaping (UIImage?) -> Void) { DispatchQueue.global(qos: .utility).async { - let image = UIImage(blurHash: blurHash, size: .Circle(radius: 12)) + let image = UIImage(blurHash: blurHash, size: size) completion(image) } } diff --git a/Shared/Views/BlurView.swift b/Shared/Views/BlurView.swift new file mode 100644 index 00000000..b627f24a --- /dev/null +++ b/Shared/Views/BlurView.swift @@ -0,0 +1,29 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI +import UIKit + +struct BlurView: UIViewRepresentable { + + let style: UIBlurEffect.Style + + init(style: UIBlurEffect.Style = .regular) { + self.style = style + } + + func makeUIView(context: Context) -> UIVisualEffectView { + let view = UIVisualEffectView(effect: UIBlurEffect(style: style)) + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + uiView.effect = UIBlurEffect(style: style) + } +} diff --git a/Shared/Views/LazyView.swift b/Shared/Views/Divider.swift similarity index 70% rename from Shared/Views/LazyView.swift rename to Shared/Views/Divider.swift index e3eea6ec..d659fc49 100644 --- a/Shared/Views/LazyView.swift +++ b/Shared/Views/Divider.swift @@ -6,12 +6,13 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // -import Foundation import SwiftUI -struct LazyView: View { - var content: () -> Content +struct Divider: View { + var body: some View { - self.content() + Color.secondarySystemFill + .frame(height: 0.5) + .padding(.horizontal) } } diff --git a/Shared/Views/ImageView.swift b/Shared/Views/ImageView.swift index 31c98fc5..7e45c574 100644 --- a/Shared/Views/ImageView.swift +++ b/Shared/Views/ImageView.swift @@ -11,9 +11,7 @@ import NukeUI import SwiftUI import UIKit -// TODO: Fix 100+ inits - -struct ImageViewSource { +struct ImageSource { let url: URL? let blurHash: String? @@ -33,25 +31,38 @@ struct DefaultFailureView: View { struct ImageView: View { @State - private var sources: [ImageViewSource] + private var sources: [ImageSource] private var currentURL: URL? { sources.first?.url } private var currentBlurHash: String? { sources.first?.blurHash } - private var failureView: FailureView + private var failureView: () -> FailureView + private var resizingMode: ImageResizingMode - init(_ source: URL?, blurHash: String? = nil, @ViewBuilder failureView: () -> FailureView) { - let imageViewSource = ImageViewSource(url: source, blurHash: blurHash) - _sources = State(initialValue: [imageViewSource]) - self.failureView = failureView() + init( + _ source: URL?, + blurHash: String? = nil, + resizingMode: ImageResizingMode = .aspectFill, + @ViewBuilder failureView: @escaping () -> FailureView + ) { + let imageSource = ImageSource(url: source, blurHash: blurHash) + self.init(imageSource, resizingMode: resizingMode, failureView: failureView) } - init(_ source: ImageViewSource, @ViewBuilder failureView: () -> FailureView) { - _sources = State(initialValue: [source]) - self.failureView = failureView() + init( + _ source: ImageSource, + resizingMode: ImageResizingMode = .aspectFill, + @ViewBuilder failureView: @escaping () -> FailureView + ) { + self.init([source], resizingMode: resizingMode, failureView: failureView) } - init(_ sources: [ImageViewSource], @ViewBuilder failureView: () -> FailureView) { + init( + _ sources: [ImageSource], + resizingMode: ImageResizingMode = .aspectFill, + @ViewBuilder failureView: @escaping () -> FailureView + ) { _sources = State(initialValue: sources) - self.failureView = failureView() + self.resizingMode = resizingMode + self.failureView = failureView } @ViewBuilder @@ -60,18 +71,20 @@ struct ImageView: View { BlurHashView(blurHash: currentBlurHash) .id(currentBlurHash) } else { - Color.secondary + Color.clear } } var body: some View { - if let currentURL = currentURL { LazyImage(source: currentURL) { state in if let image = state.image { image + .resizingMode(resizingMode) } else if state.error != nil { - placeholderView.onAppear { sources.removeFirst() } + placeholderView.onAppear { + sources.removeFirst() + } } else { placeholderView } @@ -79,27 +92,27 @@ struct ImageView: View { .pipeline(ImagePipeline(configuration: .withDataCache)) .id(currentURL) } else { - failureView + failureView() } } } extension ImageView where FailureView == DefaultFailureView { - init(_ source: URL?, blurHash: String? = nil) { - let imageViewSource = ImageViewSource(url: source, blurHash: blurHash) - self.init(imageViewSource, failureView: { DefaultFailureView() }) + init(_ source: URL?, blurHash: String? = nil, resizingMode: ImageResizingMode = .aspectFill) { + let imageSource = ImageSource(url: source, blurHash: blurHash) + self.init([imageSource], resizingMode: resizingMode, failureView: { DefaultFailureView() }) } - init(_ source: ImageViewSource) { - self.init(source, failureView: { DefaultFailureView() }) + init(_ source: ImageSource, resizingMode: ImageResizingMode = .aspectFill) { + self.init([source], resizingMode: resizingMode, failureView: { DefaultFailureView() }) } - init(_ sources: [ImageViewSource]) { - self.init(sources, failureView: { DefaultFailureView() }) + init(_ sources: [ImageSource], resizingMode: ImageResizingMode = .aspectFill) { + self.init(sources, resizingMode: resizingMode, failureView: { DefaultFailureView() }) } - init(sources: [URL]) { - let imageViewSources = sources.compactMap { ImageViewSource(url: $0, blurHash: nil) } - self.init(imageViewSources, failureView: { DefaultFailureView() }) + init(sources: [URL], resizingMode: ImageResizingMode = .aspectFill) { + let imageSources = sources.compactMap { ImageSource(url: $0, blurHash: nil) } + self.init(imageSources, resizingMode: resizingMode, failureView: { DefaultFailureView() }) } } diff --git a/Shared/Views/ParallaxHeader.swift b/Shared/Views/ParallaxHeader.swift deleted file mode 100644 index 3fa72e7a..00000000 --- a/Shared/Views/ParallaxHeader.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Foundation -import SwiftUI - -struct ParallaxHeaderScrollView: View { - var header: Header - var staticOverlayView: StaticOverlayView - var overlayAlignment: Alignment - var headerHeight: CGFloat - var content: () -> Content - - init( - header: Header, - staticOverlayView: StaticOverlayView, - overlayAlignment: Alignment = .center, - headerHeight: CGFloat, - content: @escaping () -> Content - ) { - self.header = header - self.staticOverlayView = staticOverlayView - self.overlayAlignment = overlayAlignment - self.headerHeight = headerHeight - self.content = content - } - - var body: some View { - ScrollView(showsIndicators: false) { - GeometryReader { proxy in - let yOffset = proxy.frame(in: .global).minY > 0 ? -proxy.frame(in: .global).minY : 0 - header - .frame(width: proxy.size.width, height: proxy.size.height - yOffset) - .overlay(staticOverlayView, alignment: overlayAlignment) - .offset(y: yOffset) - } - .frame(height: headerHeight) - - HStack { - content() - Spacer(minLength: 0) - } - } - } -} diff --git a/Shared/Views/TruncatedTextView.swift b/Shared/Views/TruncatedTextView.swift new file mode 100644 index 00000000..b590db09 --- /dev/null +++ b/Shared/Views/TruncatedTextView.swift @@ -0,0 +1,146 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension TruncatedTextView { + func font(_ font: Font) -> TruncatedTextView { + var result = self + result.font = font + return result + } + + func lineLimit(_ lineLimit: Int) -> TruncatedTextView { + var result = self + result.lineLimit = lineLimit + return result + } + + func foregroundColor(_ color: Color) -> TruncatedTextView { + var result = self + result.foregroundColor = color + return result + } +} + +extension String { + func heightOfString(usingFont font: UIFont) -> CGFloat { + let fontAttributes = [NSAttributedString.Key.font: font] + let textSize = self.size(withAttributes: fontAttributes) + return textSize.height + } + + func widthOfString(usingFont font: UIFont) -> CGFloat { + let fontAttributes = [NSAttributedString.Key.font: font] + let textSize = self.size(withAttributes: fontAttributes) + return textSize.width + } +} + +struct TruncatedTextView: View { + + @State + private var truncated: Bool = false + @State + private var fullSize: CGFloat = 0 + + private var font: Font = .body + private var lineLimit: Int = 3 + private var foregroundColor: Color = .primary + + let text: String + let seeMoreAction: () -> Void + let seeMoreText = "... \(L10n.seeMore)" + + public init(text: String, seeMoreAction: @escaping () -> Void) { + self.text = text + self.seeMoreAction = seeMoreAction + } + + public var body: some View { + ZStack(alignment: .bottomTrailing) { + Text(text) + .font(font) + .foregroundColor(foregroundColor) + .lineLimit(lineLimit) + .if(truncated) { text in + text.mask { + VStack(spacing: 0) { + Color.black + + HStack(spacing: 0) { + Color.black + + LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .clear, location: 0.1), + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: seeMoreText.widthOfString(usingFont: font.toUIFont()) + 15) + } + .frame(height: seeMoreText.heightOfString(usingFont: font.toUIFont())) + } + } + } + + if truncated { + #if os(tvOS) + Text(seeMoreText) + .font(font) + .foregroundColor(.purple) + #else + Button { + seeMoreAction() + } label: { + Text(seeMoreText) + .font(font) + .foregroundColor(.purple) + } + #endif + } + } + .background { + ZStack { + if !truncated { + if fullSize != 0 { + Text(text) + .font(font) + .lineLimit(lineLimit) + .background { + GeometryReader { geo in + Color.clear + .onAppear { + if fullSize > geo.size.height { + self.truncated = true + } + } + } + } + } + + Text(text) + .font(font) + .lineLimit(10) + .fixedSize(horizontal: false, vertical: true) + .background { + GeometryReader { geo in + Color.clear + .onAppear { + self.fullSize = geo.size.height + } + } + } + } + } + .hidden() + } + } +} diff --git a/Swiftfin tvOS/Components/DotHStack.swift b/Swiftfin tvOS/Components/DotHStack.swift new file mode 100644 index 00000000..addab0a3 --- /dev/null +++ b/Swiftfin tvOS/Components/DotHStack.swift @@ -0,0 +1,234 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct DotHStack: View { + + private let items: [AnyView] + private let restItems: [AnyView] + private let alignment: HorizontalAlignment + + var body: some View { + HStack(spacing: 0) { + items.first + + ForEach(0 ..< restItems.count, id: \.self) { i in + + Circle() + .frame(width: 5, height: 5) + .padding(.horizontal) + + restItems[i] + } + } + } +} + +extension DotHStack { + + init( + _ data: Data, + id: KeyPath = \.self, + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: @escaping (Data.Element) -> Content + ) { + self.alignment = alignment + self.items = data.map { content($0[keyPath: id]).eraseToAnyView() } + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> A + ) { + self.alignment = alignment + self.items = [content().eraseToAnyView()] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B)> + ) { + self.alignment = alignment + let _content = content() + + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C)> + ) { + self.alignment = alignment + let _content = content() + + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C, D)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C, D, E)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C, D, E, F)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C, D, E, F, G)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + _content.value.6.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () + -> TupleView<(A, B, C, D, E, F, G, H)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + _content.value.6.eraseToAnyView(), + _content.value.7.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () + -> TupleView<(A, B, C, D, E, F, G, H, I)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + _content.value.6.eraseToAnyView(), + _content.value.7.eraseToAnyView(), + _content.value.8.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init< + A: View, + B: View, + C: View, + D: View, + E: View, + F: View, + G: View, + H: View, + I: View, + J: View + >( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () + -> TupleView<( + A, + B, + C, + D, + E, + F, + G, + H, + I, + J + )> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + _content.value.6.eraseToAnyView(), + _content.value.7.eraseToAnyView(), + _content.value.8.eraseToAnyView(), + _content.value.9.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } +} diff --git a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift deleted file mode 100644 index 1fa33862..00000000 --- a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct EpisodeRowCard: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - let viewModel: EpisodesRowManager - let episode: BaseItemDto - - var body: some View { - VStack { - Button { - itemRouter.route(to: \.item, episode) - } label: { - ImageView( - episode.getBackdropImage(maxWidth: 550), - blurHash: episode.getBackdropImageBlurHash() - ) - .mask(Rectangle().frame(width: 550, height: 308)) - .frame(width: 550, height: 308) - } - .buttonStyle(CardButtonStyle()) - - VStack(alignment: .leading) { - - VStack(alignment: .leading) { - Text(episode.getEpisodeLocator() ?? "") - .font(.caption) - .foregroundColor(.secondary) - Text(episode.name ?? "") - .font(.footnote) - .padding(.bottom, 1) - - if episode.unaired { - Text(episode.airDateLabel ?? L10n.noOverviewAvailable) - .font(.caption) - .foregroundColor(.secondary) - .fontWeight(.light) - .lineLimit(3) - } else { - Text(episode.overview ?? "") - .font(.caption) - .fontWeight(.light) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } - } - - Spacer() - } - .padding() - .frame(width: 550) - } - .focusSection() - } -} diff --git a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift deleted file mode 100644 index c834e6d6..00000000 --- a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct EpisodesRowView: View where RowManager: EpisodesRowManager { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: RowManager - let onlyCurrentSeason: Bool - - var body: some View { - VStack(alignment: .leading) { - - Text(viewModel.selectedSeason?.name ?? L10n.episodes) - .font(.title3) - .padding(.horizontal, 50) - - ScrollView(.horizontal) { - ScrollViewReader { reader in - HStack(alignment: .top) { - if viewModel.isLoading { - VStack(alignment: .leading) { - - ZStack { - Color.secondary.ignoresSafeArea() - - ProgressView() - } - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) - - VStack(alignment: .leading) { - Text("S-E-") - .font(.caption) - .foregroundColor(.secondary) - Text("--") - .font(.footnote) - .padding(.bottom, 1) - Text("--") - .font(.caption) - .fontWeight(.light) - .lineLimit(4) - } - .padding(.horizontal) - - Spacer() - } - .frame(width: 500) - .focusable() - } else if let selectedSeason = viewModel.selectedSeason { - if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { - if seasonEpisodes.isEmpty { - VStack(alignment: .leading) { - - Color.secondary - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) - - VStack(alignment: .leading) { - Text("--") - .font(.caption) - .foregroundColor(.secondary) - L10n.noEpisodesAvailable.text - .font(.footnote) - .padding(.bottom, 1) - } - .padding(.horizontal) - - Spacer() - } - .frame(width: 500) - .focusable() - } else { - ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id: \.self) { episode in - EpisodeRowCard(viewModel: viewModel, episode: episode) - .id(episode.id) - } - } - } - } - } - .padding(.horizontal, 50) - .padding(.vertical) - .onChange(of: viewModel.selectedSeason) { _ in - if viewModel.selectedSeason?.id == viewModel.item.seasonId { - reader.scrollTo(viewModel.item.id) - } - } - .onChange(of: viewModel.seasonsEpisodes) { _ in - if viewModel.selectedSeason?.id == viewModel.item.seasonId { - reader.scrollTo(viewModel.item.id) - } - } - } - .edgesIgnoringSafeArea(.horizontal) - } - } - } -} diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift index 7baf9608..9b580cdf 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift @@ -28,10 +28,10 @@ class DynamicCinematicBackgroundViewModel: ObservableObject { let backdropImage: URL - if item.itemType == .episode { - backdropImage = item.getSeriesBackdropImage(maxWidth: 1920) + if item.type == .episode { + backdropImage = item.seriesImageURL(.backdrop, maxWidth: 1920) } else { - backdropImage = item.getBackdropImage(maxWidth: 1920) + backdropImage = item.imageURL(.backdrop, maxWidth: 1920) } let options = ImageLoadingOptions(transition: .fadeIn(duration: 0.2)) diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift index 7d41417f..1da14e2d 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift @@ -12,27 +12,27 @@ import SwiftUI struct CinematicNextUpCardView: View { @EnvironmentObject - var homeRouter: HomeCoordinator.Router + private var homeRouter: HomeCoordinator.Router let item: BaseItemDto let showOverlay: Bool var body: some View { VStack(alignment: .leading) { Button { - homeRouter.route(to: \.modalItem, item) + homeRouter.route(to: \.item, item) } label: { ZStack(alignment: .bottomLeading) { - if item.itemType == .episode { - ImageView(sources: [ - item.getSeriesThumbImage(maxWidth: 350), - item.getSeriesBackdropImage(maxWidth: 350), + if item.type == .episode { + ImageView([ + item.seriesImageSource(.thumb, maxWidth: 350), + item.seriesImageSource(.backdrop, maxWidth: 350), ]) .frame(width: 350, height: 210) } else { ImageView([ - .init(url: item.getThumbImage(maxWidth: 350)), - .init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()), + item.imageSource(.thumb, maxWidth: 350), + item.imageSource(.backdrop, maxWidth: 350), ]) .frame(width: 350, height: 210) } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift index 8b325750..b068d985 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift @@ -12,7 +12,7 @@ import SwiftUI struct CinematicResumeCardView: View { @EnvironmentObject - var homeRouter: HomeCoordinator.Router + private var homeRouter: HomeCoordinator.Router @ObservedObject var viewModel: HomeViewModel let item: BaseItemDto @@ -20,20 +20,20 @@ struct CinematicResumeCardView: View { var body: some View { VStack(alignment: .leading) { Button { - homeRouter.route(to: \.modalItem, item) + homeRouter.route(to: \.item, item) } label: { ZStack(alignment: .bottom) { - if item.itemType == .episode { - ImageView(sources: [ - item.getSeriesThumbImage(maxWidth: 350), - item.getSeriesBackdropImage(maxWidth: 350), + if item.type == .episode { + ImageView([ + item.seriesImageSource(.thumb, maxWidth: 350), + item.seriesImageSource(.backdrop, maxWidth: 350), ]) .frame(width: 350, height: 210) } else { ImageView([ - .init(url: item.getThumbImage(maxWidth: 350)), - .init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()), + item.imageSource(.thumb, maxWidth: 350), + item.imageSource(.backdrop, maxWidth: 350), ]) .frame(width: 350, height: 210) } @@ -54,7 +54,7 @@ struct CinematicResumeCardView: View { .foregroundColor(.white) HStack { - Color(UIColor.systemPurple) + Color.jellyfinPurple .frame(width: 350 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) Spacer(minLength: 0) diff --git a/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift b/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift index f32d594e..54d234f8 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift @@ -54,7 +54,7 @@ struct HomeCinematicView: View { ZStack(alignment: .bottom) { CinematicBackgroundView(viewModel: backgroundViewModel) - .frame(height: UIScreen.main.bounds.height - 10) + .frame(height: UIScreen.main.bounds.height - 50) LinearGradient( stops: [ @@ -77,8 +77,8 @@ struct HomeCinematicView: View { .fontWeight(.medium) .foregroundColor(Color.secondary) } else { - if updatedSelectedItem?.itemType == .episode { - Text(updatedSelectedItem?.getEpisodeLocator() ?? "") + if updatedSelectedItem?.type == .episode { + Text(updatedSelectedItem?.episodeLocator ?? "") .font(.callout) .fontWeight(.medium) .foregroundColor(Color.secondary) diff --git a/Swiftfin tvOS/Components/ItemDetailsView.swift b/Swiftfin tvOS/Components/ItemDetailsView.swift index e31d3fb1..8804e4e4 100644 --- a/Swiftfin tvOS/Components/ItemDetailsView.swift +++ b/Swiftfin tvOS/Components/ItemDetailsView.swift @@ -8,6 +8,8 @@ import SwiftUI +// TODO: Replace and remove + struct ItemDetailsView: View { @ObservedObject @@ -28,9 +30,9 @@ struct ItemDetailsView: View { .font(.title3) .padding(.bottom, 5) - ForEach(viewModel.informationItems, id: \.self.title) { informationItem in - ItemDetail(title: informationItem.title, content: informationItem.content) - } + // ForEach(viewModel.informationItems, id: \.self.title) { informationItem in + // ItemDetail(title: informationItem.title, content: informationItem.content) + // } } Spacer() diff --git a/Swiftfin tvOS/Components/LandscapeItemElement.swift b/Swiftfin tvOS/Components/LandscapeItemElement.swift index 7a030398..9bef8c91 100644 --- a/Swiftfin tvOS/Components/LandscapeItemElement.swift +++ b/Swiftfin tvOS/Components/LandscapeItemElement.swift @@ -54,65 +54,61 @@ struct LandscapeItemElement: View { var body: some View { VStack { - ImageView( - item.type == .episode && !(inSeasonView ?? false) ? item.getSeriesBackdropImage(maxWidth: 445) : item - .getBackdropImage(maxWidth: 445), - blurHash: item.type == .episode ? item.getSeriesBackdropImageBlurHash() : item.getBackdropImageBlurHash() - ) - .frame(width: 445, height: 250) - .cornerRadius(10) - .ignoresSafeArea() - .overlay( - ZStack { - if item.userData?.played ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(.systemBlue)) - } - }.padding(2) - .opacity(1), - alignment: .topTrailing - ).opacity(1) - .overlay(ZStack(alignment: .leading) { - if focused && item.userData?.playedPercentage != nil { - Rectangle() - .fill(LinearGradient( - gradient: Gradient(colors: [.black, .clear]), - startPoint: .bottom, - endPoint: .top - )) - .frame(width: 445, height: 90) - .mask(CutOffShadow()) - VStack(alignment: .leading) { - Text("CONTINUE • \(item.getItemProgressString() ?? "")") - .font(.caption) - .fontWeight(.medium) - .offset(y: 5) - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12) + ImageView(item.imageSource(.backdrop, maxWidth: 445)) + .frame(width: 445, height: 250) + .cornerRadius(10) + .ignoresSafeArea() + .overlay( + ZStack { + if item.userData?.played ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(.systemBlue)) } - }.padding(12) - } else { - EmptyView() - } - }, alignment: .bottomLeading) - .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) - .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) + }.padding(2) + .opacity(1), + alignment: .topTrailing + ).opacity(1) + .overlay(ZStack(alignment: .leading) { + if focused && item.userData?.playedPercentage != nil { + Rectangle() + .fill(LinearGradient( + gradient: Gradient(colors: [.black, .clear]), + startPoint: .bottom, + endPoint: .top + )) + .frame(width: 445, height: 90) + .mask(CutOffShadow()) + VStack(alignment: .leading) { + Text("CONTINUE • \(item.getItemProgressString() ?? "")") + .font(.caption) + .fontWeight(.medium) + .offset(y: 5) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12) + } + }.padding(12) + } else { + EmptyView() + } + }, alignment: .bottomLeading) + .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) + .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) if inSeasonView ?? false { - Text("\(item.getEpisodeLocator() ?? "") • \(item.name ?? "")") + Text("\(item.episodeLocator ?? "") • \(item.name ?? "")") .font(.callout) .fontWeight(.semibold) .lineLimit(1) .frame(width: 445) } else { - Text(item.type == .episode ? "\(item.seriesName ?? "") • \(item.getEpisodeLocator() ?? "")" : item.name ?? "") + Text(item.type == .episode ? "\(item.seriesName ?? "") • \(item.episodeLocator ?? "")" : item.name ?? "") .font(.callout) .fontWeight(.semibold) .lineLimit(1) @@ -123,16 +119,6 @@ struct LandscapeItemElement: View { withAnimation(.linear(duration: 0.15)) { self.focused = envFocus } - - if envFocus == true { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // your code here - if focused == true { - backgroundURL = item.getBackdropImage(maxWidth: 1080) - BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) - } - } - } } .scaleEffect(focused ? 1.1 : 1) } diff --git a/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift b/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift deleted file mode 100644 index 5ef2baeb..00000000 --- a/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct MediaPlayButtonRowView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: ItemViewModel - @State - var wrappedScrollView: UIScrollView? - - var body: some View { - HStack { - VStack { - Button { - itemRouter.route(to: \.videoPlayer, viewModel.selectedVideoPlayerViewModel!) - } label: { - MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) - } - - Text((viewModel.item.getItemProgressString() != nil) ? "\(viewModel.item.getItemProgressString() ?? "") left" : L10n.play) - .font(.caption) - } - VStack { - Button { - viewModel.updateWatchState() - } label: { - MediaViewActionButton(icon: "eye.fill", scrollView: $wrappedScrollView, iconColor: viewModel.isWatched ? .red : .white) - } - Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") - .font(.caption) - } - VStack { - Button { - viewModel.updateFavoriteState() - } label: { - MediaViewActionButton( - icon: "heart.fill", - scrollView: $wrappedScrollView, - iconColor: viewModel.isFavorited ? .red : .white - ) - } - Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") - .font(.caption) - } - Spacer() - } - } -} diff --git a/Swiftfin tvOS/Components/MediaViewActionButton.swift b/Swiftfin tvOS/Components/MediaViewActionButton.swift deleted file mode 100644 index 2b696273..00000000 --- a/Swiftfin tvOS/Components/MediaViewActionButton.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct MediaViewActionButton: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - var icon: String - var scrollView: Binding? - var iconColor: Color? - - var body: some View { - Image(systemName: icon) - .foregroundColor(focused ? .black : iconColor ?? .white) - .onChange(of: envFocused) { envFocus in - if envFocus == true { - scrollView?.wrappedValue?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - scrollView?.wrappedValue?.scrollToTop() - } - } - - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .font(.system(size: 40)) - .padding(.vertical, 12).padding(.horizontal, 20) - } -} diff --git a/Swiftfin tvOS/Components/PlainLinkButton.swift b/Swiftfin tvOS/Components/PlainLinkButton.swift deleted file mode 100644 index 236882ad..00000000 --- a/Swiftfin tvOS/Components/PlainLinkButton.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct PlainLinkButton: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - @State - var label: String - - var body: some View { - Text(label) - .fontWeight(focused ? .bold : .regular) - .foregroundColor(.blue) - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .scaleEffect(focused ? 1.1 : 1) - } -} diff --git a/Swiftfin tvOS/Components/PortraitButton.swift b/Swiftfin tvOS/Components/PortraitButton.swift new file mode 100644 index 00000000..8f001216 --- /dev/null +++ b/Swiftfin tvOS/Components/PortraitButton.swift @@ -0,0 +1,58 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI +import SwiftUICollection + +struct PortraitButton: View { + + let item: Item + let selectedAction: (Item) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 15) { + Button { + selectedAction(item) + } label: { + ImageView( + item.portraitPosterImageSource(maxWidth: 300), + failureView: { + InitialFailureView(item.title.initials) + } + ) + .frame(width: 270, height: 405) + } + .buttonStyle(CardButtonStyle()) + + VStack(alignment: .leading) { + if item.showTitle { + HStack { + Text(item.title) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + .lineLimit(2) + .frame(width: 250) + + Spacer() + } + } + + if let subtitle = item.subtitle { + Text(subtitle) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + .zIndex(-1) + .frame(maxWidth: .infinity) + } + .focusSection() + } +} diff --git a/Swiftfin tvOS/Components/PortraitItemElement.swift b/Swiftfin tvOS/Components/PortraitItemElement.swift index 2981c214..b3d280df 100644 --- a/Swiftfin tvOS/Components/PortraitItemElement.swift +++ b/Swiftfin tvOS/Components/PortraitItemElement.swift @@ -9,7 +9,9 @@ import JellyfinAPI import SwiftUI +// TODO: Transition to `PortraitButton` struct PortraitItemElement: View { + @Environment(\.isFocused) var envFocused: Bool @State @@ -21,49 +23,46 @@ struct PortraitItemElement: View { var body: some View { VStack { - ImageView( - item.type == .episode ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), - blurHash: item.type == .episode ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash() - ) - .frame(width: 200, height: 300) - .cornerRadius(10) - .shadow(radius: focused ? 10.0 : 0) - .shadow(radius: focused ? 10.0 : 0) - .overlay( - ZStack { - if item.userData?.isFavorite ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - .opacity(0.6) - Image(systemName: "heart.fill") - .foregroundColor(Color(.systemRed)) - .font(.system(size: 10)) - } - } - .padding(2) - .opacity(1), - alignment: .bottomLeading - ) - .overlay( - ZStack { - if item.userData?.played ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(.systemBlue)) - } else { - if item.userData?.unplayedItemCount != nil { + ImageView(item.type == .episode ? item.seriesImageSource(.primary, maxWidth: 200) : item.imageSource(.primary, maxWidth: 200)) + .frame(width: 200, height: 300) + .cornerRadius(10) + .shadow(radius: focused ? 10.0 : 0) + .shadow(radius: focused ? 10.0 : 0) + .overlay( + ZStack { + if item.userData?.isFavorite ?? false { Image(systemName: "circle.fill") - .foregroundColor(Color(.systemBlue)) - Text(String(item.userData!.unplayedItemCount ?? 0)) .foregroundColor(.white) - .font(.caption2) + .opacity(0.6) + Image(systemName: "heart.fill") + .foregroundColor(Color(.systemRed)) + .font(.system(size: 10)) } } - }.padding(2) + .padding(2) .opacity(1), - alignment: .topTrailing - ).opacity(1) + alignment: .bottomLeading + ) + .overlay( + ZStack { + if item.userData?.played ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(.systemBlue)) + } else { + if item.userData?.unplayedItemCount != nil { + Image(systemName: "circle.fill") + .foregroundColor(Color(.systemBlue)) + Text(String(item.userData!.unplayedItemCount ?? 0)) + .foregroundColor(.white) + .font(.caption2) + } + } + }.padding(2) + .opacity(1), + alignment: .topTrailing + ).opacity(1) Text(item.title) .frame(width: 200, height: 30, alignment: .center) if item.type == .movie || item.type == .series { @@ -87,16 +86,6 @@ struct PortraitItemElement: View { withAnimation(.linear(duration: 0.15)) { self.focused = envFocus } - - if envFocus == true { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // your code here - if focused == true { - backgroundURL = item.getBackdropImage(maxWidth: 1080) - BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) - } - } - } } .scaleEffect(focused ? 1.1 : 1) } diff --git a/Swiftfin tvOS/Components/PortraitItemsRowView.swift b/Swiftfin tvOS/Components/PortraitItemsRowView.swift deleted file mode 100644 index 9cfa6cd9..00000000 --- a/Swiftfin tvOS/Components/PortraitItemsRowView.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct PortraitItemsRowView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - - let rowTitle: String - let items: [BaseItemDto] - let showItemTitles: Bool - let selectedAction: (BaseItemDto) -> Void - - init( - rowTitle: String, - items: [BaseItemDto], - showItemTitles: Bool = true, - selectedAction: @escaping (BaseItemDto) -> Void - ) { - self.rowTitle = rowTitle - self.items = items - self.showItemTitles = showItemTitles - self.selectedAction = selectedAction - } - - var body: some View { - VStack(alignment: .leading) { - - Text(rowTitle) - .font(.title3) - .padding(.horizontal, 50) - - ScrollView(.horizontal) { - HStack(alignment: .top) { - ForEach(items, id: \.self) { item in - - VStack(spacing: 15) { - Button { - selectedAction(item) - } label: { - ImageView(item.portraitHeaderViewURL(maxWidth: 257)) - .frame(width: 257, height: 380) - } - .frame(height: 380) - .buttonStyle(PlainButtonStyle()) - - if showItemTitles { - Text(item.title) - .lineLimit(2) - .frame(width: 257) - } - } - } - } - .padding(.horizontal, 50) - .padding(.vertical) - } - .edgesIgnoringSafeArea(.horizontal) - } - .focusSection() - } -} diff --git a/Swiftfin tvOS/Components/PortraitPosterHStack.swift b/Swiftfin tvOS/Components/PortraitPosterHStack.swift new file mode 100644 index 00000000..749646ac --- /dev/null +++ b/Swiftfin tvOS/Components/PortraitPosterHStack.swift @@ -0,0 +1,89 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI +import SwiftUICollection +import TVUIKit + +struct PortraitPosterHStack: View { + + private let loading: Bool + private let title: String + private let items: [Item] + private let selectedAction: (Item) -> Void + private let trailingContent: () -> TrailingContent + + init( + loading: Bool = false, + title: String, + items: [Item], + @ViewBuilder trailingContent: @escaping () -> TrailingContent, + selectedAction: @escaping (Item) -> Void + ) { + self.loading = loading + self.title = title + self.items = items + self.trailingContent = trailingContent + self.selectedAction = selectedAction + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + Text(title) + .font(.title3) + .fontWeight(.semibold) + .padding(.leading, 50) + + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 0) { + if loading { + ForEach(0 ..< 10) { _ in + PortraitButton( + item: BaseItemDto.placeHolder, + selectedAction: { _ in } + ) + .redacted(reason: .placeholder) + } + } else if items.isEmpty { + PortraitButton( + item: BaseItemDto.noResults, + selectedAction: { _ in } + ) + } else { + ForEach(items, id: \.hashValue) { item in + PortraitButton(item: item) { item in + selectedAction(item) + } + } + } + + trailingContent() + } + .padding(.horizontal, 50) + .padding2(.vertical) + } + } + } +} + +extension PortraitPosterHStack where TrailingContent == EmptyView { + init( + loading: Bool = false, + title: String, + items: [Item], + selectedAction: @escaping (Item) -> Void + ) { + self.loading = loading + self.title = title + self.items = items + self.trailingContent = { EmptyView() } + self.selectedAction = selectedAction + } +} diff --git a/Swiftfin tvOS/Components/PublicUserButton.swift b/Swiftfin tvOS/Components/PublicUserButton.swift deleted file mode 100644 index af8af7e3..00000000 --- a/Swiftfin tvOS/Components/PublicUserButton.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import CoreMedia -import JellyfinAPI -import SwiftUI - -struct PublicUserButton: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - var publicUser: UserDto - - var body: some View { - VStack { - if publicUser.primaryImageTag != nil { - ImageView( - URL( - string: "\(SessionManager.main.currentLogin.server.currentURI)/Users/\(publicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(publicUser.primaryImageTag!)" - )! - ) - .frame(width: 250, height: 250) - .cornerRadius(125.0) - } else { - Image(systemName: "person.fill") - .foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8)) - .font(.system(size: 35)) - .frame(width: 250, height: 250) - .background(Color(red: 98 / 255, green: 121 / 255, blue: 205 / 255)) - .cornerRadius(125.0) - .shadow(radius: 6) - } - if focused { - Text(publicUser.name ?? "").font(.headline).fontWeight(.semibold) - } else { - Spacer().frame(height: 60) - } - }.onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - }.scaleEffect(focused ? 1.1 : 1) - } -} diff --git a/Swiftfin tvOS/Objects/FocusGuide.swift b/Swiftfin tvOS/Objects/FocusGuide.swift new file mode 100644 index 00000000..f0c2898f --- /dev/null +++ b/Swiftfin tvOS/Objects/FocusGuide.swift @@ -0,0 +1,149 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct FocusGuideModifier: ViewModifier { + + @FocusState + var focusDirection: FocusDirection? + @EnvironmentObject + var focusGuide: FocusGuide + + let focusConstructor: FocusConstructor + let onContentFocus: (() -> Void)? + + let debug = false + + func body(content: Content) -> some View { + VStack(spacing: 0) { + + Color(debug ? .red : .clear) + .frame(height: 1) + .if(focusConstructor.topTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .top) + + HStack(spacing: 0) { + Color(debug ? .red : .clear) + .frame(width: 1) + .if(focusConstructor.leftTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .left) + + content + .focused($focusDirection, equals: .content) + + Color(debug ? .red : .clear) + .frame(width: 1) + .if(focusConstructor.rightTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .right) + } + + Color(debug ? .red : .clear) + .frame(height: 1) + .if(focusConstructor.bottomTarget != nil, transform: { boundary in + boundary.focusable() + }) + .focused($focusDirection, equals: .bottom) + } + .onChange(of: focusDirection) { focusDirection in + guard let focusDirection = focusDirection else { return } + switch focusDirection { + case .top: + focusGuide.transition(to: focusConstructor.topTarget!) + case .bottom: + focusGuide.transition(to: focusConstructor.bottomTarget!) + case .left: + focusGuide.transition(to: focusConstructor.leftTarget!) + case .right: + focusGuide.transition(to: focusConstructor.rightTarget!) + case .content: () + } + } + .onChange(of: focusGuide.focusedTag) { newTag in + if newTag == focusConstructor.tag { + if let onContentFocus = onContentFocus { + onContentFocus() + } else { + focusDirection = .content + } + } + } + } +} + +extension View { + func focusGuide( + _ focusGuide: FocusGuide, + tag: String, + onContentFocus: (() -> Void)? = nil, + top: String? = nil, + bottom: String? = nil, + left: String? = nil, + right: String? = nil + ) -> some View { + let focusConstructor = FocusConstructor( + tag: tag, + topTarget: top, + bottomTarget: bottom, + leftTarget: left, + rightTarget: right + ) + return modifier(FocusGuideModifier(focusConstructor: focusConstructor, onContentFocus: onContentFocus)) + .environmentObject(focusGuide) + } +} + +enum FocusDirection: String { + case top + case bottom + case content + case left + case right +} + +struct FocusConstructor { + + let tag: String + let topTarget: String? + let bottomTarget: String? + let leftTarget: String? + let rightTarget: String? + + init( + tag: String, + topTarget: String?, + bottomTarget: String?, + leftTarget: String?, + rightTarget: String? + ) { + self.tag = tag + self.topTarget = topTarget + self.bottomTarget = bottomTarget + self.leftTarget = leftTarget + self.rightTarget = rightTarget + } +} + +class FocusGuide: ObservableObject { + + @Published + private(set) var focusedTag: String? + + private(set) var lastFocusedTag: String? + + func transition(to tag: String?) { + lastFocusedTag = focusedTag + focusedTag = tag + } +} diff --git a/Swiftfin tvOS/Views/AboutView.swift b/Swiftfin tvOS/Views/AboutAppView.swift similarity index 92% rename from Swiftfin tvOS/Views/AboutView.swift rename to Swiftfin tvOS/Views/AboutAppView.swift index fbe2fa7a..0fa86c4c 100644 --- a/Swiftfin tvOS/Views/AboutView.swift +++ b/Swiftfin tvOS/Views/AboutAppView.swift @@ -8,7 +8,7 @@ import SwiftUI -struct AboutView: View { +struct AboutAppView: View { var body: some View { Text("dud") diff --git a/Swiftfin tvOS/Views/BasicAppSettingsView.swift b/Swiftfin tvOS/Views/BasicAppSettingsView.swift index 9f111845..e1174ea3 100644 --- a/Swiftfin tvOS/Views/BasicAppSettingsView.swift +++ b/Swiftfin tvOS/Views/BasicAppSettingsView.swift @@ -13,7 +13,7 @@ import SwiftUI struct BasicAppSettingsView: View { @EnvironmentObject - var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router + private var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router @ObservedObject var viewModel: BasicAppSettingsViewModel @State diff --git a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift index 560e3232..f7d35a9f 100644 --- a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift +++ b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift @@ -12,21 +12,24 @@ import SwiftUI struct ContinueWatchingCard: View { @EnvironmentObject - var homeRouter: HomeCoordinator.Router + private var homeRouter: HomeCoordinator.Router let item: BaseItemDto var body: some View { VStack(alignment: .leading) { Button { - homeRouter.route(to: \.modalItem, item) + homeRouter.route(to: \.item, item) } label: { ZStack(alignment: .bottom) { - if item.itemType == .episode { - ImageView(item.getSeriesBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) + if item.type == .episode { + ImageView([ + item.seriesImageSource(.thumb, maxWidth: 500), + item.imageSource(.primary, maxWidth: 500), + ]) + .frame(width: 500, height: 281.25) } else { - ImageView(item.getBackdropImage(maxWidth: 500)) + ImageView(item.imageURL(.backdrop, maxWidth: 500)) .frame(width: 500, height: 281.25) } @@ -66,8 +69,8 @@ struct ContinueWatchingCard: View { .lineLimit(1) .frame(width: 500, alignment: .leading) - if item.itemType == .episode { - Text(item.getEpisodeLocator() ?? "") + if item.type == .episode { + Text(item.episodeLocator ?? "--") .font(.callout) .fontWeight(.medium) .foregroundColor(.secondary) diff --git a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift index 84138a66..389e7225 100644 --- a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift +++ b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift @@ -14,7 +14,7 @@ import SwiftUI struct ContinueWatchingView: View { @EnvironmentObject - var homeRouter: HomeCoordinator.Router + private var homeRouter: HomeCoordinator.Router let items: [BaseItemDto] var body: some View { diff --git a/Swiftfin tvOS/Views/HomeView.swift b/Swiftfin tvOS/Views/HomeView.swift index 8a41da9b..5183e114 100644 --- a/Swiftfin tvOS/Views/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView.swift @@ -8,20 +8,16 @@ import Defaults import Foundation +import Introspect import JellyfinAPI import SwiftUI struct HomeView: View { @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @StateObject - var viewModel = HomeViewModel() - @Default(.showPosterLabels) - var showPosterLabels - - @State - var showingSettings = false + private var router: HomeCoordinator.Router + @ObservedObject + var viewModel: HomeViewModel var body: some View { if viewModel.isLoading { @@ -39,8 +35,12 @@ struct HomeView: View { ) if !viewModel.nextUpItems.isEmpty { - NextUpView(items: viewModel.nextUpItems) - .focusSection() + PortraitPosterHStack( + title: L10n.nextUp, + items: viewModel.nextUpItems + ) { item in + router.route(to: \.item, item) + } } } else { HomeCinematicView( @@ -49,38 +49,27 @@ struct HomeView: View { ) if !viewModel.nextUpItems.isEmpty { - NextUpView(items: viewModel.nextUpItems) - .focusSection() + PortraitPosterHStack( + title: L10n.nextUp, + items: viewModel.nextUpItems + ) { item in + router.route(to: \.item, item) + } } - PortraitItemsRowView( - rowTitle: L10n.recentlyAdded, - items: viewModel.latestAddedItems, - showItemTitles: showPosterLabels - ) { item in - homeRouter.route(to: \.modalItem, item) + if !viewModel.latestAddedItems.isEmpty { + PortraitPosterHStack( + title: L10n.recentlyAdded, + items: viewModel.latestAddedItems + ) { item in + router.route(to: \.item, item) + } } } ForEach(viewModel.libraries, id: \.self) { library in - LatestMediaView(viewModel: LatestMediaViewModel(library: library)) - .focusSection() + LatestInLibraryView(viewModel: LatestMediaViewModel(library: library)) } - - Spacer(minLength: 100) - - HStack { - Spacer() - - Button { - viewModel.refresh() - } label: { - L10n.refresh.text - } - - Spacer() - } - .focusSection() } } .edgesIgnoringSafeArea(.top) diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicCollectionItemView.swift similarity index 61% rename from Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift rename to Swiftfin tvOS/Views/ItemView/CinematicCollectionItemView.swift index f2dbace1..d9501d88 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicCollectionItemView.swift @@ -46,28 +46,24 @@ struct CinematicCollectionItemView: View { Color.black.ignoresSafeArea() - VStack(alignment: .leading, spacing: 20) { - - CinematicItemAboutView(viewModel: viewModel) - - PortraitItemsRowView( - rowTitle: L10n.items, - items: viewModel.collectionItems - ) { item in - itemRouter.route(to: \.item, item) - } - - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView( - rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels - ) { item in - itemRouter.route(to: \.item, item) - } - } - } - .padding(.vertical, 50) + // VStack(alignment: .leading, spacing: 20) { +// + // CinematicItemAboutView(viewModel: viewModel) +// + // PortraitImageHStack(rowTitle: L10n.items, + // items: viewModel.collectionItems) { item in + // itemRouter.route(to: \.item, item) + // } +// + // if !viewModel.similarItems.isEmpty { + // PortraitImageHStack(rowTitle: L10n.recommended, + // items: viewModel.similarItems, + // showItemTitles: showPosterLabels) { item in + // itemRouter.route(to: \.item, item) + // } + // } + // } + // .padding(.vertical, 50) } } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicEpisodeItemView.swift similarity index 66% rename from Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift rename to Swiftfin tvOS/Views/ItemView/CinematicEpisodeItemView.swift index cd924aee..b1f8f599 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicEpisodeItemView.swift @@ -22,7 +22,7 @@ struct CinematicEpisodeItemView: View { var showPosterLabels func generateSubtitle() -> String? { - guard let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() else { + guard let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.episodeLocator else { return nil } @@ -60,27 +60,23 @@ struct CinematicEpisodeItemView: View { CinematicItemAboutView(viewModel: viewModel) - EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) - .focusSection() + // EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) + // .focusSection() - if let seriesItem = viewModel.series { - PortraitItemsRowView( - rowTitle: L10n.series, - items: [seriesItem] - ) { seriesItem in - itemRouter.route(to: \.item, seriesItem) - } - } + // if let seriesItem = viewModel.series { + // PortraitItemsRowView(rowTitle: L10n.series, + // items: [seriesItem]) { seriesItem in + // itemRouter.route(to: \.item, seriesItem) + // } + // } - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView( - rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels - ) { item in - itemRouter.route(to: \.item, item) - } - } + // if !viewModel.similarItems.isEmpty { + // PortraitImageHStack(rowTitle: L10n.recommended, + // items: viewModel.similarItems, + // showItemTitles: showPosterLabels) { item in + // itemRouter.route(to: \.item, item) + // } + // } ItemDetailsView(viewModel: viewModel) } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemAboutView.swift similarity index 100% rename from Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift rename to Swiftfin tvOS/Views/ItemView/CinematicItemAboutView.swift diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift deleted file mode 100644 index 16566153..00000000 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct CinematicItemViewTopRowButton: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - @State - var wrappedScrollView: UIScrollView? - var content: () -> Content - - @FocusState - private var buttonFocused: Bool - - var body: some View { - content() - .focused($buttonFocused) - .onChange(of: envFocused) { envFocus in - if envFocus == true { - wrappedScrollView?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - wrappedScrollView?.scrollToTop() - } - } - - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .onChange(of: buttonFocused) { newValue in - if newValue { - wrappedScrollView?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - wrappedScrollView?.scrollToTop() - } - - withAnimation(.linear(duration: 0.15)) { - self.focused = newValue - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift deleted file mode 100644 index b6ca0df4..00000000 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Introspect -import SwiftUI - -struct CinematicMovieItemView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: MovieItemViewModel - @State - var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) - var showPosterLabels - - var body: some View { - ZStack { - - ImageView( - viewModel.item.getBackdropImage(maxWidth: 1920), - blurHash: viewModel.item.getBackdropImageBlurHash() - ) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - - CinematicItemViewTopRow( - viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: nil - ) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - - ZStack(alignment: .topLeading) { - - Color.black.ignoresSafeArea() - - VStack(alignment: .leading, spacing: 20) { - - CinematicItemAboutView(viewModel: viewModel) - - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView( - rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels - ) { item in - itemRouter.route(to: \.item, item) - } - } - - ItemDetailsView(viewModel: viewModel) - } - .padding(.top, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift deleted file mode 100644 index 582ba340..00000000 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct CinematicSeasonItemView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: SeasonItemViewModel - @State - var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) - var showPosterLabels - - var body: some View { - ZStack { - - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - - if let seriesItem = viewModel.seriesItem { - CinematicItemViewTopRow( - viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: seriesItem.name - ) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - } else { - CinematicItemViewTopRow( - viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "" - ) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - } - - ZStack(alignment: .topLeading) { - - Color.black.ignoresSafeArea() - .frame(minHeight: UIScreen.main.bounds.height) - - VStack(alignment: .leading, spacing: 20) { - - CinematicItemAboutView(viewModel: viewModel) - - EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) - .focusSection() - - if let seriesItem = viewModel.seriesItem { - PortraitItemsRowView( - rowTitle: L10n.series, - items: [seriesItem] - ) { seriesItem in - itemRouter.route(to: \.item, seriesItem) - } - } - - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView( - rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels - ) { item in - itemRouter.route(to: \.item, item) - } - } - } - .padding(.vertical, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift deleted file mode 100644 index 7be04155..00000000 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct CinematicSeriesItemView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: SeriesItemViewModel - @State - var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) - var showPosterLabels - - var body: some View { - ZStack { - - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - - CinematicItemViewTopRow( - viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: nil - ) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - - ZStack(alignment: .topLeading) { - - Color.black.ignoresSafeArea() - .frame(minHeight: UIScreen.main.bounds.height) - - VStack(alignment: .leading, spacing: 20) { - - CinematicItemAboutView(viewModel: viewModel) - - PortraitItemsRowView( - rowTitle: L10n.seasons, - items: viewModel.seasons, - showItemTitles: showPosterLabels - ) { season in - itemRouter.route(to: \.item, season) - } - - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView( - rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels - ) { item in - itemRouter.route(to: \.item, item) - } - } - } - .padding(.vertical, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemViewTopRow.swift similarity index 63% rename from Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift rename to Swiftfin tvOS/Views/ItemView/CinematicItemViewTopRow.swift index 7a031001..553e6e2c 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemViewTopRow.swift @@ -57,49 +57,9 @@ struct CinematicItemViewTopRow: View { HStack(alignment: .bottom) { VStack(alignment: .leading) { - HStack(alignment: .PlayInformationAlignmentGuide) { - - // MARK: Play - - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - HStack(spacing: 15) { - Image(systemName: "play.fill") - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) - .font(.title3) - Text(playButtonText) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) - .fontWeight(.semibold) - } - .frame(width: 230, height: 100) - .background(viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white) - .cornerRadius(10) - } - .buttonStyle(CardButtonStyle()) - .contextMenu { - if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - Label(L10n.playFromBeginning, systemImage: "gobackward") - } - - Button(role: .cancel) {} label: { - L10n.cancel.text - } - } - } - } + // HStack(alignment: .PlayInformationAlignmentGuide) { +// + // } } VStack(alignment: .leading, spacing: 5) { @@ -111,7 +71,7 @@ struct CinematicItemViewTopRow: View { Text(subtitle) } - HStack(alignment: .PlayInformationAlignmentGuide, spacing: 20) { + HStack(spacing: 20) { if showDetails { if viewModel.item.itemType == .series { @@ -191,14 +151,3 @@ struct CinematicItemViewTopRow: View { } } } - -extension VerticalAlignment { - - private struct PlayInformationAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[VerticalAlignment.bottom] - } - } - - static let PlayInformationAlignmentGuide = VerticalAlignment(PlayInformationAlignment.self) -} diff --git a/Swiftfin tvOS/Views/ItemView/CinematicSeasonItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicSeasonItemView.swift new file mode 100644 index 00000000..485a654c --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/CinematicSeasonItemView.swift @@ -0,0 +1,84 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +struct CinematicSeasonItemView: View { + + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeasonItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels + + var body: some View { + ZStack { + + ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + + // if let seriesItem = viewModel.seriesItem { + // CinematicItemViewTopRow(viewModel: viewModel, + // wrappedScrollView: wrappedScrollView, + // title: viewModel.item.name ?? "", + // subtitle: seriesItem.name) + // .focusSection() + // .frame(height: UIScreen.main.bounds.height - 10) + // } else { + // CinematicItemViewTopRow(viewModel: viewModel, + // wrappedScrollView: wrappedScrollView, + // title: viewModel.item.name ?? "") + // .focusSection() + // .frame(height: UIScreen.main.bounds.height - 10) + // } + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + // EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) + // .focusSection() + + // if let seriesItem = viewModel.seriesItem { + // PortraitItemsRowView(rowTitle: L10n.series, + // items: [seriesItem]) { seriesItem in + // itemRouter.route(to: \.item, seriesItem) + // } + // } + + // if !viewModel.similarItems.isEmpty { + // PortraitImageHStack(rowTitle: L10n.recommended, + // items: viewModel.similarItems, + // showItemTitles: showPosterLabels) { item in + // itemRouter.route(to: \.item, item) + // } + // } + } + .padding(.vertical, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift new file mode 100644 index 00000000..01d6debe --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift @@ -0,0 +1,111 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension CollectionItemView { + + struct ContentView: View { + + @ObservedObject + var viewModel: CollectionItemViewModel + @State + var scrollViewProxy: ScrollViewProxy + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + private var focusGuide = FocusGuide() + @State + private var showLogo: Bool = false + + var body: some View { + VStack(spacing: 0) { + + ItemView.CinematicHeaderView(viewModel: viewModel) + .focusGuide(focusGuide, tag: "mediaButtons", bottom: "items") + .frame(height: UIScreen.main.bounds.height - 150) + .padding(.bottom, 50) + + VStack(spacing: 0) { + + Color.clear + .frame(height: 0.5) + .id("topContentDivider") + + if showLogo { + ImageView( + viewModel.item.imageSource(.logo, maxWidth: 500), + resizingMode: .aspectFit, + failureView: { + Text(viewModel.item.displayName) + .font(.largeTitle) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + } + ) + .frame(width: 500, height: 150) + .padding(.top, 5) + } + + PortraitPosterHStack( + title: L10n.items, + items: viewModel.collectionItems + ) { item in + itemRouter.route(to: \.item, item) + } + .focusGuide(focusGuide, tag: "items", top: "mediaButtons", bottom: "about") + + ItemView.AboutView(viewModel: viewModel) + .focusGuide(focusGuide, tag: "about", top: "items") + + Spacer() + } + .frame(minHeight: UIScreen.main.bounds.height) + } + .background { + BlurView(style: .dark) + .mask { + VStack(spacing: 0) { + LinearGradient( + stops: [ + .init(color: .clear, location: 0.5), + .init(color: .white.opacity(0.8), location: 0.7), + .init(color: .white.opacity(0.8), location: 0.95), + .init(color: .white, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: UIScreen.main.bounds.height - 150) + + Color.white + } + } + } + .onChange(of: focusGuide.focusedTag) { newTag in + if newTag == "items" && !showLogo { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + withAnimation(.easeIn(duration: 0.35)) { + scrollViewProxy.scrollTo("topContentDivider") + } + } + withAnimation { + self.showLogo = true + } + } else if newTag == "mediaButtons" { + withAnimation { + self.showLogo = false + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift new file mode 100644 index 00000000..0cf9e67a --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemView.swift @@ -0,0 +1,21 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct CollectionItemView: View { + + @ObservedObject + var viewModel: CollectionItemViewModel + + var body: some View { + ItemView.CinematicScrollView(viewModel: viewModel) { scrollViewProxy in + ContentView(viewModel: viewModel, scrollViewProxy: scrollViewProxy) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift deleted file mode 100644 index 36bb84cc..00000000 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct EpisodeItemView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: EpisodeItemViewModel - - @State - var actors: [BaseItemPerson] = [] - @State - var studio: String? - @State - var director: String? - - func onAppear() { - actors = [] - director = nil - studio = nil - var actor_index = 0 - viewModel.item.people?.forEach { person in - if person.type == "Actor" { - if actor_index < 4 { - actors.append(person) - } - actor_index = actor_index + 1 - } - if person.type == "Director" { - director = person.name ?? "" - } - } - - studio = viewModel.item.studios?.first?.name ?? nil - } - - var body: some View { - ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - LazyVStack(alignment: .leading) { - Text(viewModel.item.name ?? "") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - Text(viewModel.item.seriesName ?? "") - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1) - ) - } - Spacer() - }.padding(.top, -15) - - HStack(alignment: .top) { - VStack(alignment: .trailing) { - if studio != nil { - L10n.studio.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(studio!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } - - if director != nil { - L10n.director.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(director!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } - - if !actors.isEmpty { - L10n.cast.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - ForEach(actors, id: \.id) { person in - Text(person.name!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - Spacer() - } - VStack(alignment: .leading) { - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) - - MediaPlayButtonRowView(viewModel: viewModel) - .environmentObject(itemRouter) - } - }.padding(.top, 50) - - if !viewModel.similarItems.isEmpty { - L10n.moreLikeThis.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItem in - Button { - itemRouter.route(to: \.item, similarItem) - } label: { - PortraitItemElement(item: similarItem) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - Spacer() - Spacer() - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) - }.onAppear(perform: onAppear) - } -} diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift deleted file mode 100644 index c30c247c..00000000 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct MovieItemView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: MovieItemViewModel - - @State - var actors: [BaseItemPerson] = [] - @State - var studio: String? - @State - var director: String? - @State - var wrappedScrollView: UIScrollView? - - @Namespace - private var namespace - - func onAppear() { - actors = [] - director = nil - studio = nil - var actor_index = 0 - viewModel.item.people?.forEach { person in - if person.type == "Actor" { - if actor_index < 4 { - actors.append(person) - } - actor_index = actor_index + 1 - } - if person.type == "Director" { - director = person.name ?? "" - } - } - - studio = viewModel.item.studios?.first?.name ?? nil - } - - var body: some View { - ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading) { - Text(viewModel.item.name ?? "") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1) - ) - } - } - - HStack { - VStack(alignment: .trailing) { - if studio != nil { - L10n.studio.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(studio!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } - - if director != nil { - L10n.director.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(director!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } - - if !actors.isEmpty { - L10n.cast.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - ForEach(actors, id: \.id) { person in - Text(person.name!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - Spacer() - } - VStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines?.first ?? "") - .font(.body) - .italic() - .fontWeight(.medium) - .foregroundColor(.primary) - } - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) - - MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) - .padding(.top, 15) - } - }.padding(.top, 50) - - if !viewModel.similarItems.isEmpty { - L10n.moreLikeThis.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItem in - Button { - itemRouter.route(to: \.item, similarItem) - } label: { - PortraitItemElement(item: similarItem) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) - }.introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - }.onAppear(perform: onAppear) - .focusScope(namespace) - } -} - -extension UIScrollView { - func scrollToTop() { - let desiredOffset = CGPoint(x: 0, y: 0) - setContentOffset(desiredOffset, animated: true) - } -} diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift deleted file mode 100644 index 4c05691a..00000000 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct SeasonItemView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: SeasonItemViewModel - @State - var wrappedScrollView: UIScrollView? - - @Environment(\.resetFocus) - var resetFocus - @Namespace - private var namespace - - var body: some View { - ZStack { - ImageView(viewModel.item.getSeriesBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getSeriesBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading) { - Text("\(viewModel.item.seriesName ?? "") • \(viewModel.item.name ?? "")") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1) - ) - } - if viewModel.item.communityRating != nil { - HStack { - Image(systemName: "star.fill") - .foregroundColor(.yellow) - .font(.subheadline) - Text(String(viewModel.item.communityRating!)).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - - VStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines?.first ?? "") - .font(.body) - .italic() - .fontWeight(.medium) - .foregroundColor(.primary) - } - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) - - HStack { - VStack { - Button { - viewModel.updateFavoriteState() - } label: { - MediaViewActionButton( - icon: "heart.fill", - scrollView: $wrappedScrollView, - iconColor: viewModel.isFavorited ? .red : .white - ) - }.prefersDefaultFocus(in: namespace) - Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") - .font(.caption) - } - - VStack { - Button { - viewModel.updateWatchState() - } label: { - MediaViewActionButton( - icon: "eye.fill", - scrollView: $wrappedScrollView, - iconColor: viewModel.isWatched ? .red : .white - ) - } - Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") - .font(.caption) - } - }.padding(.top, 15) - Spacer() - }.padding(.top, 50) - - if !viewModel.episodes.isEmpty { - L10n.episodes.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - - ForEach(viewModel.episodes, id: \.id) { episode in - - Button { - itemRouter.route(to: \.item, episode) - } label: { - LandscapeItemElement(item: episode, inSeasonView: true) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift deleted file mode 100644 index 86886313..00000000 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct SeriesItemView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: SeriesItemViewModel - - @State - var actors: [BaseItemPerson] = [] - @State - var studio: String? - @State - var director: String? - - @State - var wrappedScrollView: UIScrollView? - - @Environment(\.resetFocus) - var resetFocus - @Namespace - private var namespace - - func onAppear() { - actors = [] - director = nil - studio = nil - var actor_index = 0 - viewModel.item.people?.forEach { person in - if person.type == "Actor" { - if actor_index < 4 { - actors.append(person) - } - actor_index = actor_index + 1 - } - if person.type == "Director" { - director = person.name ?? "" - } - } - - studio = viewModel.item.studios?.first?.name ?? nil - } - - var body: some View { - ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading) { - Text(viewModel.item.name ?? "") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1) - ) - } - if viewModel.item.communityRating != nil { - HStack { - Image(systemName: "star.fill") - .foregroundColor(.yellow) - .font(.subheadline) - Text(String(viewModel.item.communityRating!)).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - - HStack { - VStack(alignment: .trailing) { - if studio != nil { - L10n.studio.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(studio!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } - - if director != nil { - L10n.director.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(director!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } - - if !actors.isEmpty { - L10n.cast.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - ForEach(actors, id: \.id) { person in - Text(person.name!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - Spacer() - } - VStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines?.first ?? "") - .font(.body) - .italic() - .fontWeight(.medium) - .foregroundColor(.primary) - } - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) - - MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) - .padding(.top, 15) - Spacer() - } - }.padding(.top, 50) - if !viewModel.seasons.isEmpty { - L10n.seasons.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - - ForEach(viewModel.seasons, id: \.id) { season in - Button { - itemRouter.route(to: \.item, season) - } label: { - PortraitItemElement(item: season) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - - if !viewModel.similarItems.isEmpty { - L10n.moreLikeThis.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItems in - NavigationLink(destination: ItemView(item: similarItems)) { - PortraitItemElement(item: similarItems) - }.buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) - }.focusScope(namespace) - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - }.onAppear(perform: onAppear) - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift new file mode 100644 index 00000000..d527f26c --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift @@ -0,0 +1,108 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct AboutView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + + @State + private var presentOverviewAlert = false + @State + private var presentSubtitlesAlert = false + @State + private var presentAudioAlert = false + + var body: some View { + VStack(alignment: .leading) { + + L10n.about.text + .font(.title3) + .fontWeight(.semibold) + .padding(.leading, 50) + + ScrollView(.horizontal) { + HStack { + ImageView( + viewModel.item.type == .episode ? viewModel.item.seriesImageSource(.primary, maxWidth: 300) : viewModel.item + .imageSource(.primary, maxWidth: 300), + failureView: { + InitialFailureView(viewModel.item.title.initials) + } + ) + .portraitPoster(width: 270) + + AboutViewCard( + isShowingAlert: $presentOverviewAlert, + title: viewModel.item.displayName, + text: viewModel.item.overview ?? L10n.noOverviewAvailable + ) + + if let subtitleStreams = viewModel.playButtonItem?.subtitleStreams, !subtitleStreams.isEmpty { + AboutViewCard( + isShowingAlert: $presentSubtitlesAlert, + title: L10n.subtitles, + text: subtitleStreams.compactMap(\.displayTitle).joined(separator: ", ") + ) + } + + if let audioStreams = viewModel.playButtonItem?.audioStreams, !audioStreams.isEmpty { + AboutViewCard( + isShowingAlert: $presentAudioAlert, + title: L10n.audio, + text: audioStreams.compactMap(\.displayTitle).joined(separator: ", ") + ) + } + } + .padding(.horizontal, 50) + .padding(.top) + .padding(.bottom, 100) + } + } + .alert(viewModel.item.displayName, isPresented: $presentOverviewAlert) { + Button { + presentOverviewAlert = false + } label: { + L10n.close.text + } + } message: { + if let overview = viewModel.item.overview { + overview.text + } else { + L10n.noOverviewAvailable.text + } + } + .alert(L10n.subtitles, isPresented: $presentSubtitlesAlert) { + Button { + presentSubtitlesAlert = false + } label: { + L10n.close.text + } + } message: { + viewModel.item.subtitleStreams.compactMap(\.displayTitle).joined(separator: ", ") + .text + } + .alert(L10n.audio, isPresented: $presentAudioAlert) { + Button { + presentAudioAlert = false + } label: { + L10n.close.text + } + } message: { + viewModel.item.audioStreams.compactMap(\.displayTitle).joined(separator: ", ") + .text + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift new file mode 100644 index 00000000..82e58290 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutViewCard.swift @@ -0,0 +1,43 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView.AboutView { + + struct AboutViewCard: View { + + @Binding + var isShowingAlert: Bool + + let title: String + let text: String + + var body: some View { + Button { + isShowingAlert = true + } label: { + VStack(alignment: .leading) { + title.text + .font(.title3) + .fontWeight(.semibold) + .lineLimit(2) + + Spacer() + + TruncatedTextView(text: text, seeMoreAction: {}) + .font(.subheadline) + .lineLimit(4) + } + .padding2() + .frame(width: 700, height: 405) + } + .buttonStyle(CardButtonStyle()) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift new file mode 100644 index 00000000..553ffd27 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack.swift @@ -0,0 +1,56 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct ActionButtonHStack: View { + + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + HStack { + Button { + viewModel.toggleWatchState() + } label: { + Group { + if viewModel.isWatched { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.jellyfinPurple) + } else { + Image(systemName: "checkmark.circle") + } + } + .font(.title3) + .frame(height: 100) + .frame(maxWidth: .infinity) + } + .buttonStyle(PlainButtonStyle()) + + Button { + viewModel.toggleFavoriteState() + } label: { + Group { + if viewModel.isFavorited { + Image(systemName: "heart.fill") + .foregroundColor(.red) + } else { + Image(systemName: "heart") + } + } + .font(.title3) + .frame(height: 100) + .frame(maxWidth: .infinity) + } + .buttonStyle(PlainButtonStyle()) + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift new file mode 100644 index 00000000..52613c2d --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift @@ -0,0 +1,54 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct AttributesHStack: View { + + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + HStack(spacing: 0) { + if let officialRating = viewModel.item.officialRating { + AttributeOutlineView(text: officialRating) + .padding(.trailing) + } + + if let selectedPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + if selectedPlayerViewModel.item.isHD ?? false { + AttributeFillView(text: "HD") + .padding(.trailing) + } + + if (selectedPlayerViewModel.videoStream.width ?? 0) > 3800 { + AttributeFillView(text: "4K") + .padding(.trailing) + } + + if selectedPlayerViewModel.audioStreams.contains(where: { $0.channelLayout == "5.1" }) { + AttributeFillView(text: "5.1") + .padding(.trailing) + } + + if selectedPlayerViewModel.audioStreams.contains(where: { $0.channelLayout == "7.1" }) { + AttributeFillView(text: "7.1") + .padding(.trailing) + } + + if !selectedPlayerViewModel.subtitleStreams.isEmpty { + AttributeOutlineView(text: "CC") + } + } + } + .foregroundColor(Color(UIColor.darkGray)) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemContentView.swift new file mode 100644 index 00000000..dc4d11fd --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemContentView.swift @@ -0,0 +1,187 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension EpisodeItemView { + + struct ContentView: View { + + @ObservedObject + var viewModel: EpisodeItemViewModel + @State + var scrollViewProxy: ScrollViewProxy + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + private var focusGuide = FocusGuide() + @State + private var showName: Bool = false + + var body: some View { + VStack { + Self.EpisodeCinematicHeaderView(viewModel: viewModel) + .focusGuide(focusGuide, tag: "mediaButtons", bottom: "recommended") + .frame(height: UIScreen.main.bounds.height - 150) + .padding(.bottom, 50) + + VStack(spacing: 0) { + + Color.clear + .frame(height: 0.5) + .id("topContentDivider") + + if showName { + Text(viewModel.item.displayName) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(1) + .foregroundColor(.white) + } + + PortraitPosterHStack( + title: L10n.recommended, + items: viewModel.similarItems + ) { item in + itemRouter.route(to: \.item, item) + } + .focusGuide(focusGuide, tag: "recommended", top: "mediaButtons", bottom: "about") + + ItemView.AboutView(viewModel: viewModel) + .focusGuide(focusGuide, tag: "about", top: "recommended") + + Spacer() + } + .frame(minHeight: UIScreen.main.bounds.height) + } + .background { + BlurView() + .mask { + VStack(spacing: 0) { + LinearGradient(gradient: Gradient(stops: [ + .init(color: .white, location: 0), + .init(color: .white.opacity(0.5), location: 0.6), + .init(color: .white.opacity(0), location: 1), + ]), startPoint: .bottom, endPoint: .top) + .frame(height: UIScreen.main.bounds.height - 150) + + Color.white + } + } + } + .onChange(of: focusGuide.focusedTag) { newTag in + if newTag == "recommended" && !showName { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + withAnimation(.easeIn(duration: 0.35)) { + scrollViewProxy.scrollTo("topContentDivider") + } + } + withAnimation { + self.showName = true + } + } else if newTag == "mediaButtons" { + withAnimation { + self.showName = false + } + } + } + } + } +} + +extension EpisodeItemView.ContentView { + + struct EpisodeCinematicHeaderView: View { + + enum CinematicHeaderFocusLayer: Hashable { + case top + case playButton + } + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @FocusState + private var focusedLayer: CinematicHeaderFocusLayer? + @EnvironmentObject + private var focusGuide: FocusGuide + @ObservedObject + var viewModel: EpisodeItemViewModel + + var body: some View { + VStack(alignment: .leading) { + + Color.clear + .focusable() + .focused($focusedLayer, equals: .top) + + HStack(alignment: .bottom) { + + VStack(alignment: .leading, spacing: 20) { + + if let seriesName = viewModel.item.seriesName { + Text(seriesName) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + } + + Text(viewModel.item.displayName) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(1) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + + if let overview = viewModel.item.overview { + Text(overview) + .font(.subheadline) + .lineLimit(4) + } else { + L10n.noOverviewAvailable.text + } + + HStack { + DotHStack { + if let premiereYear = viewModel.item.premiereDateYear { + premiereYear.text + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + runtime.text + } + } + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + + ItemView.AttributesHStack(viewModel: viewModel) + } + } + + Spacer() + + VStack { + ItemView.PlayButton(viewModel: viewModel) + .focused($focusedLayer, equals: .playButton) + + ItemView.ActionButtonHStack(viewModel: viewModel) + .frame(width: 400) + } + .frame(width: 450) + .padding(.leading, 150) + } + } + .padding(.horizontal, 50) + .onChange(of: focusedLayer) { layer in + if layer == .top { + focusedLayer = .playButton + } + } + } + } +} diff --git a/Shared/Extensions/ImageExtensions.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemView.swift similarity index 50% rename from Shared/Extensions/ImageExtensions.swift rename to Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemView.swift index 6870fc36..9fb69b5b 100644 --- a/Shared/Extensions/ImageExtensions.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemView.swift @@ -6,17 +6,16 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // -import Foundation import SwiftUI -extension Image { - func centerCropped() -> some View { - GeometryReader { geo in - self - .resizable() - .scaledToFill() - .frame(width: geo.size.width, height: geo.size.height) - .clipped() +struct EpisodeItemView: View { + + @ObservedObject + var viewModel: EpisodeItemViewModel + + var body: some View { + ItemView.CinematicScrollView(viewModel: viewModel) { scrollViewProxy in + ContentView(viewModel: viewModel, scrollViewProxy: scrollViewProxy) } } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift new file mode 100644 index 00000000..a7cf99f5 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/Components/PlayButton.swift @@ -0,0 +1,71 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct PlayButton: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + @FocusState + var isFocused: Bool + + var body: some View { + Button { + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + } else { + LogManager.log.error("Attempted to play item but no playback information available") + } + } label: { + HStack(spacing: 15) { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) + .font(.title3) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) + .fontWeight(.semibold) + } + .frame(width: 400, height: 100) + .background { + if isFocused { + viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white + } else { + Color.white + .opacity(0.5) + } + } + .cornerRadius(10) + } + .focused($isFocused) + .buttonStyle(CardButtonStyle()) + .contextMenu { + if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { + Button { + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) + itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + } else { + LogManager.log.error("Attempted to play item but no playback information available") + } + } label: { + Label(L10n.playFromBeginning, systemImage: "gobackward") + } + + Button(role: .cancel) {} label: { + L10n.cancel.text + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/ItemView.swift b/Swiftfin tvOS/Views/ItemView/ItemView.swift index 5f51f3e3..46d3c04c 100644 --- a/Swiftfin tvOS/Views/ItemView/ItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/ItemView.swift @@ -11,8 +11,8 @@ import Introspect import JellyfinAPI import SwiftUI -// Useless view necessary in tvOS because of iOS's implementation -struct ItemNavigationView: View { +struct ItemView: View { + private let item: BaseItemDto init(item: BaseItemDto) { @@ -20,53 +20,17 @@ struct ItemNavigationView: View { } var body: some View { - ItemView(item: item) - } -} - -struct ItemView: View { - - @Default(.tvOSCinematicViews) - var tvOSCinematicViews - - private var item: BaseItemDto - - init(item: BaseItemDto) { - self.item = item - } - - var body: some View { - Group { - switch item.itemType { - case .movie: - if tvOSCinematicViews { - CinematicMovieItemView(viewModel: MovieItemViewModel(item: item)) - } else { - MovieItemView(viewModel: MovieItemViewModel(item: item)) - } - case .episode: - if tvOSCinematicViews { - CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) - } else { - EpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) - } - case .season: - if tvOSCinematicViews { - CinematicSeasonItemView(viewModel: SeasonItemViewModel(item: item)) - } else { - SeasonItemView(viewModel: .init(item: item)) - } - case .series: - if tvOSCinematicViews { - CinematicSeriesItemView(viewModel: SeriesItemViewModel(item: item)) - } else { - SeriesItemView(viewModel: SeriesItemViewModel(item: item)) - } - case .boxset, .folder: - CinematicCollectionItemView(viewModel: CollectionItemViewModel(item: item)) - default: - Text(L10n.notImplementedYetWithType(item.type ?? "")) - } + switch item.type { + case .movie: + MovieItemView(viewModel: .init(item: item)) + case .episode: + EpisodeItemView(viewModel: .init(item: item)) + case .series: + SeriesItemView(viewModel: .init(item: item)) + case .boxSet: + CollectionItemView(viewModel: .init(item: item)) + default: + Text(L10n.notImplementedYetWithType(item.type ?? "--")) } } } diff --git a/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift new file mode 100644 index 00000000..c9bd0d60 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift @@ -0,0 +1,111 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension MovieItemView { + + struct ContentView: View { + + @ObservedObject + var viewModel: MovieItemViewModel + @State + var scrollViewProxy: ScrollViewProxy + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + private var focusGuide = FocusGuide() + @State + private var showLogo: Bool = false + + var body: some View { + VStack(spacing: 0) { + + ItemView.CinematicHeaderView(viewModel: viewModel) + .focusGuide(focusGuide, tag: "mediaButtons", bottom: "recommended") + .frame(height: UIScreen.main.bounds.height - 150) + .padding(.bottom, 50) + + VStack(spacing: 0) { + + Color.clear + .frame(height: 0.5) + .id("topContentDivider") + + if showLogo { + ImageView( + viewModel.item.imageSource(.logo, maxWidth: 500), + resizingMode: .aspectFit, + failureView: { + Text(viewModel.item.displayName) + .font(.largeTitle) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + } + ) + .frame(width: 500, height: 150) + .padding(.top, 5) + } + + PortraitPosterHStack( + title: L10n.recommended, + items: viewModel.similarItems + ) { item in + itemRouter.route(to: \.item, item) + } + .focusGuide(focusGuide, tag: "recommended", top: "mediaButtons", bottom: "about") + + ItemView.AboutView(viewModel: viewModel) + .focusGuide(focusGuide, tag: "about", top: "recommended") + + Spacer() + } + .frame(minHeight: UIScreen.main.bounds.height) + } + .background { + BlurView(style: .dark) + .mask { + VStack(spacing: 0) { + LinearGradient( + stops: [ + .init(color: .clear, location: 0.5), + .init(color: .white.opacity(0.8), location: 0.7), + .init(color: .white.opacity(0.8), location: 0.95), + .init(color: .white, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: UIScreen.main.bounds.height - 150) + + Color.white + } + } + } + .onChange(of: focusGuide.focusedTag) { newTag in + if newTag == "recommended" && !showLogo { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + withAnimation(.easeIn(duration: 0.35)) { + scrollViewProxy.scrollTo("topContentDivider") + } + } + withAnimation { + self.showLogo = true + } + } else if newTag == "mediaButtons" { + withAnimation { + self.showLogo = false + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemView.swift b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemView.swift new file mode 100644 index 00000000..5394bb36 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemView.swift @@ -0,0 +1,21 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct MovieItemView: View { + + @ObservedObject + var viewModel: MovieItemViewModel + + var body: some View { + ItemView.CinematicScrollView(viewModel: viewModel) { scrollViewProxy in + ContentView(viewModel: viewModel, scrollViewProxy: scrollViewProxy) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift new file mode 100644 index 00000000..5d5473dc --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift @@ -0,0 +1,129 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +extension ItemView { + + struct CinematicScrollView: View { + + @ObservedObject + var viewModel: ItemViewModel + + let content: (ScrollViewProxy) -> Content + + var body: some View { + ZStack { + if viewModel.item.type == .episode { + ImageView(viewModel.item.imageSource(.primary, maxWidth: 1920)) + } else { + ImageView(viewModel.item.imageSource(.backdrop, maxWidth: 1920)) + } + + ScrollView(.vertical, showsIndicators: false) { + ScrollViewReader { scrollViewProxy in + content(scrollViewProxy) + } + } + } + .ignoresSafeArea() + } + } +} + +extension ItemView { + + struct CinematicHeaderView: View { + + enum CinematicHeaderFocusLayer: Hashable { + case top + case playButton + } + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + @EnvironmentObject + var focusGuide: FocusGuide + @FocusState + private var focusedLayer: CinematicHeaderFocusLayer? + + var body: some View { + VStack(alignment: .leading) { + + Color.clear + .focusable() + .focused($focusedLayer, equals: .top) + + HStack(alignment: .bottom) { + + VStack(alignment: .leading, spacing: 20) { + + ImageView( + viewModel.item.imageSource(.logo, maxWidth: 500), + resizingMode: .aspectFit, + failureView: { + Text(viewModel.item.displayName) + .font(.largeTitle) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + } + ) + .frame(maxWidth: 500, maxHeight: 200) + + Text(viewModel.item.overview ?? L10n.noOverviewAvailable) + .font(.subheadline) + .lineLimit(3) + + HStack { + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + firstGenre.text + } + + if let premiereYear = viewModel.item.premiereDateYear { + premiereYear.text + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + runtime.text + } + } + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + + ItemView.AttributesHStack(viewModel: viewModel) + } + } + + Spacer() + + VStack { + ItemView.PlayButton(viewModel: viewModel) + .focused($focusedLayer, equals: .playButton) + + ItemView.ActionButtonHStack(viewModel: viewModel) + .frame(width: 400) + } + .frame(width: 450) + .padding(.leading, 150) + } + } + .padding(.horizontal, 50) + .onChange(of: focusedLayer) { layer in + if layer == .top { + focusedLayer = .playButton + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift new file mode 100644 index 00000000..64c1b7ac --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/EpisodeCard.swift @@ -0,0 +1,84 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Combine +import JellyfinAPI +import SwiftUI + +struct EpisodeCard: View { + + @EnvironmentObject + private var router: ItemCoordinator.Router + @State + private var cancellables = Set() + + let episode: BaseItemDto + + var body: some View { + VStack(alignment: .center, spacing: 20) { + Button { + // TODO: Figure out ad-hoc video player view model creation + episode.createVideoPlayerViewModel() + .sink(receiveCompletion: { _ in }) { viewModels in + guard !viewModels.isEmpty else { return } + self.router.route(to: \.videoPlayer, viewModels[0]) + } + .store(in: &cancellables) + } label: { + ImageView( + episode.imageSource(.primary, maxWidth: 600) + ) { + InitialFailureView(episode.title.initials) + } + .frame(width: 550, height: 308) + } + .buttonStyle(CardButtonStyle()) + + Button { + router.route(to: \.item, episode) + } label: { + VStack(alignment: .leading) { + + VStack(alignment: .leading, spacing: 0) { + Color.clear + .frame(height: 0.01) + .frame(maxWidth: .infinity) + + Text(episode.episodeLocator ?? L10n.unknown) + .font(.caption) + .foregroundColor(.secondary) + } + + Text(episode.displayName) + .font(.footnote) + .padding(.bottom, 1) + + if episode.unaired { + Text(episode.airDateLabel ?? L10n.noOverviewAvailable) + .font(.caption) + .lineLimit(1) + } else { + Text(episode.overview ?? L10n.noOverviewAvailable) + .font(.caption) + .lineLimit(3) + } + + Spacer(minLength: 0) + + L10n.seeMore.text + .font(.caption) + .fontWeight(.medium) + .foregroundColor(Color(UIColor.systemCyan)) + } + .frame(width: 510, height: 220) + .padding() + } + .buttonStyle(CardButtonStyle()) + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift new file mode 100644 index 00000000..39470ea1 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/Components/SeriesEpisodesView.swift @@ -0,0 +1,153 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Introspect +import JellyfinAPI +import SwiftUI + +struct SeriesEpisodesView: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + @FocusState + private var isFocused: Bool + @EnvironmentObject + private var parentFocusGuide: FocusGuide + + var body: some View { + VStack(spacing: 0) { + SeasonsHStack(viewModel: viewModel) + .environmentObject(parentFocusGuide) + + EpisodesHStack(viewModel: viewModel) + .environmentObject(parentFocusGuide) + } + } +} + +extension SeriesEpisodesView { + + // MARK: SeasonsHStack + + struct SeasonsHStack: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + @EnvironmentObject + private var focusGuide: FocusGuide + @FocusState + private var focusedSeason: BaseItemDto? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(viewModel.sortedSeasons, id: \.self) { season in + Button {} label: { + Text(season.displayName) + .fontWeight(.semibold) + .fixedSize() + .padding(.vertical, 10) + .padding(.horizontal, 20) + .if(viewModel.selectedSeason == season) { text in + text + .background(Color.white) + .foregroundColor(.black) + } + } + .buttonStyle(PlainButtonStyle()) + .id(season) + .focused($focusedSeason, equals: season) + } + } + .focusGuide( + focusGuide, + tag: "seasons", + onContentFocus: { focusedSeason = viewModel.selectedSeason }, + top: "mediaButtons", + bottom: "episodes" + ) + .frame(height: 70) + .padding(.horizontal, 50) + .padding(.top) + .padding(.bottom, 45) + } + .onChange(of: focusedSeason) { season in + guard let season = season else { return } + viewModel.select(season: season) + } + } + } +} + +extension SeriesEpisodesView { + + // MARK: EpisodesHStack + + struct EpisodesHStack: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + + @EnvironmentObject + private var focusGuide: FocusGuide + @FocusState + private var focusedEpisodeID: String? + @State + private var lastFocusedEpisodeID: String? + @State + private var currentEpisodes: [BaseItemDto] = [] + @State + private var wrappedScrollView: UIScrollView? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 40) { + if !currentEpisodes.isEmpty { + ForEach(currentEpisodes, id: \.self) { episode in + EpisodeCard(episode: episode) + .focused($focusedEpisodeID, equals: episode.id) + } + } else { + ForEach(1 ..< 10) { i in + EpisodeCard(episode: .placeHolder) + .redacted(reason: .placeholder) + .focused($focusedEpisodeID, equals: "\(i)") + } + } + } + .padding(.horizontal, 50) + .padding(.bottom, 50) + .padding(.top) + } + .focusGuide( + focusGuide, + tag: "episodes", + onContentFocus: { focusedEpisodeID = lastFocusedEpisodeID }, + top: "seasons", + bottom: "recommended" + ) + .transition(.opacity) + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .onChange(of: viewModel.selectedSeason) { _ in + currentEpisodes = viewModel.currentEpisodes ?? [] + lastFocusedEpisodeID = currentEpisodes.first?.id + wrappedScrollView?.scrollToTop(animated: false) + } + .onChange(of: focusedEpisodeID) { episodeIndex in + guard let episodeIndex = episodeIndex else { return } + lastFocusedEpisodeID = episodeIndex + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + currentEpisodes = viewModel.currentEpisodes ?? [] + lastFocusedEpisodeID = currentEpisodes.first?.id + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift new file mode 100644 index 00000000..a49c1ad6 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift @@ -0,0 +1,121 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation +import SwiftUI + +extension SeriesItemView { + + struct ContentView: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + @State + var scrollViewProxy: ScrollViewProxy + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + private var focusGuide = FocusGuide() + @State + private var showLogo: Bool = false + + var body: some View { + VStack(spacing: 0) { + + ItemView.CinematicHeaderView(viewModel: viewModel) + .focusGuide(focusGuide, tag: "mediaButtons", bottom: "seasons") + .frame(height: UIScreen.main.bounds.height - 150) + .padding(.bottom, 50) + + VStack(spacing: 0) { + + Color.clear + .frame(height: 0.5) + .id("topContentDivider") + + if showLogo { + ImageView( + viewModel.item.imageSource(.logo, maxWidth: 500), + resizingMode: .aspectFit, + failureView: { + Text(viewModel.item.displayName) + .font(.largeTitle) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + } + ) + .frame(width: 500, height: 150) + .padding(.top, 5) + } + + SeriesEpisodesView(viewModel: viewModel) + .environmentObject(focusGuide) + + Color.clear + .frame(height: 0.5) + .id("seasonsRecommendedContentDivider") + + PortraitPosterHStack( + title: L10n.recommended, + items: viewModel.similarItems + ) { item in + itemRouter.route(to: \.item, item) + } + .focusGuide(focusGuide, tag: "recommended", top: "seasons", bottom: "about") + + ItemView.AboutView(viewModel: viewModel) + .focusGuide(focusGuide, tag: "about", top: "recommended") + + Spacer() + } + .frame(minHeight: UIScreen.main.bounds.height) + } + .background { + BlurView(style: .dark) + .mask { + VStack(spacing: 0) { + LinearGradient(gradient: Gradient(stops: [ + .init(color: .white, location: 0), + .init(color: .white.opacity(0.7), location: 0.4), + .init(color: .white.opacity(0), location: 1), + ]), startPoint: .bottom, endPoint: .top) + .frame(height: UIScreen.main.bounds.height - 150) + + Color.white + } + } + } + .onChange(of: focusGuide.focusedTag) { newTag in + if newTag == "seasons" && !showLogo { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + withAnimation(.easeIn(duration: 0.35)) { + scrollViewProxy.scrollTo("topContentDivider") + } + } + withAnimation { + self.showLogo = true + } + } else if newTag == "mediaButtons" { + withAnimation { + self.showLogo = false + } + } else if newTag == "recommended" && focusGuide.lastFocusedTag == "episodes" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + withAnimation(.easeIn(duration: 0.35)) { + scrollViewProxy.scrollTo("seasonsRecommendedContentDivider") + } + } + } + } + } + } +} diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift new file mode 100644 index 00000000..3a816e25 --- /dev/null +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemView.swift @@ -0,0 +1,21 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct SeriesItemView: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + + var body: some View { + ItemView.CinematicScrollView(viewModel: viewModel) { scrollViewProxy in + ContentView(viewModel: viewModel, scrollViewProxy: scrollViewProxy) + } + } +} diff --git a/Swiftfin tvOS/Views/LatestInLibraryView.swift b/Swiftfin tvOS/Views/LatestInLibraryView.swift new file mode 100644 index 00000000..c90f8cd6 --- /dev/null +++ b/Swiftfin tvOS/Views/LatestInLibraryView.swift @@ -0,0 +1,56 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct LatestInLibraryView: View { + + @EnvironmentObject + private var router: HomeCoordinator.Router + @StateObject + var viewModel: LatestMediaViewModel + + var body: some View { + PortraitPosterHStack( + title: L10n.latestWithString(viewModel.library.displayName), + items: viewModel.items + ) { + Button { + router.route(to: \.library, ( + viewModel: .init( + parentID: viewModel.library.id!, + filters: LibraryFilters( + filters: [], + sortOrder: [.descending], + sortBy: [.dateAdded] + ) + ), + title: viewModel.library.displayName + )) + } label: { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) + + VStack(spacing: 20) { + Image(systemName: "chevron.right") + .font(.title) + + L10n.seeAll.text + .font(.title3) + } + } + } + .frame(width: 257, height: 380) + .buttonStyle(PlainButtonStyle()) + } selectedAction: { item in + router.route(to: \.item, item) + } + } +} diff --git a/Swiftfin tvOS/Views/LatestMediaView.swift b/Swiftfin tvOS/Views/LatestMediaView.swift deleted file mode 100644 index 0f1d9b53..00000000 --- a/Swiftfin tvOS/Views/LatestMediaView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct LatestMediaView: View { - - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @StateObject - var viewModel: LatestMediaViewModel - @Default(.showPosterLabels) - var showPosterLabels - - var body: some View { - VStack(alignment: .leading) { - - L10n.latestWithString(viewModel.library.name ?? "").text - .font(.title3) - .padding(.horizontal, 50) - - ScrollView(.horizontal) { - HStack(alignment: .top) { - ForEach(viewModel.items, id: \.self) { item in - - VStack(spacing: 15) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - ImageView(item.portraitHeaderViewURL(maxWidth: 257)) - .frame(width: 257, height: 380) - } - .frame(height: 380) - .buttonStyle(PlainButtonStyle()) - - if showPosterLabels { - Text(item.title) - .lineLimit(2) - .frame(width: 257) - } - } - } - - Button { - homeRouter.route(to: \.library, ( - viewModel: .init( - parentID: viewModel.library.id!, - filters: LibraryFilters( - filters: [], - sortOrder: [.descending], - sortBy: [.dateAdded] - ) - ), - title: viewModel.library.name ?? "" - )) - } label: { - ZStack { - Color(UIColor.darkGray) - .opacity(0.5) - - VStack(spacing: 20) { - Image(systemName: "chevron.right") - .font(.title) - - L10n.seeAll.text - .font(.title3) - } - } - } - .frame(width: 257, height: 380) - .buttonStyle(PlainButtonStyle()) - } - .padding(.horizontal, 50) - .padding(.vertical) - } - .edgesIgnoringSafeArea(.horizontal) - } - .focusSection() - } -} diff --git a/Swiftfin tvOS/Views/LibraryFilterView.swift b/Swiftfin tvOS/Views/LibraryFilterView.swift index be385783..76b28f1e 100644 --- a/Swiftfin tvOS/Views/LibraryFilterView.swift +++ b/Swiftfin tvOS/Views/LibraryFilterView.swift @@ -13,7 +13,7 @@ import SwiftUI struct LibraryFilterView: View { @EnvironmentObject - var filterRouter: FilterCoordinator.Router + private var filterRouter: FilterCoordinator.Router @Binding var filters: LibraryFilters var parentId: String = "" diff --git a/Swiftfin tvOS/Views/LibraryListView.swift b/Swiftfin tvOS/Views/LibraryListView.swift index 0dc81467..855787cd 100644 --- a/Swiftfin tvOS/Views/LibraryListView.swift +++ b/Swiftfin tvOS/Views/LibraryListView.swift @@ -6,7 +6,6 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // -import Defaults import Foundation import JellyfinAPI import Stinsen @@ -16,34 +15,18 @@ struct LibraryListView: View { @EnvironmentObject var mainCoordinator: MainCoordinator.Router @EnvironmentObject - var libraryListRouter: LibraryListCoordinator.Router + private var libraryListRouter: LibraryListCoordinator.Router @StateObject var viewModel = LibraryListViewModel() - @Default(.Experimental.liveTVAlphaEnabled) - var liveTVAlphaEnabled - - var supportedCollectionTypes: [BaseItemDto.ItemType] { - if liveTVAlphaEnabled { - return [.movie, .season, .series, .liveTV, .boxset, .unknown] - } else { - return [.movie, .season, .series, .boxset, .unknown] - } - } - var body: some View { ScrollView { LazyVStack { if !viewModel.isLoading { - ForEach(viewModel.libraries.filter { [self] library in - let collectionType = library.collectionType ?? "other" - let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown - return self.supportedCollectionTypes.contains(itemType) - }, id: \.id) { library in + ForEach(viewModel.filteredLibraries, id: \.id) { library in Button { - let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown - if itemType == .liveTV { + if library.collectionType == "livetv" { self.mainCoordinator.root(\.liveTV) } else { self.libraryListRouter.route( diff --git a/Swiftfin tvOS/Views/LibraryView.swift b/Swiftfin tvOS/Views/LibraryView.swift index 23db4df4..6d1ee4c0 100644 --- a/Swiftfin tvOS/Views/LibraryView.swift +++ b/Swiftfin tvOS/Views/LibraryView.swift @@ -12,7 +12,7 @@ import SwiftUICollection struct LibraryView: View { @EnvironmentObject - var libraryRouter: LibraryCoordinator.Router + private var libraryRouter: LibraryCoordinator.Router @StateObject var viewModel: LibraryViewModel var title: String @@ -67,7 +67,7 @@ struct LibraryView: View { GeometryReader { _ in if let item = cell.item { Button { - libraryRouter.route(to: \.modalItem, item) + libraryRouter.route(to: \.item, item) } label: { PortraitItemElement(item: item) } diff --git a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift index 38c5d61c..5aa16125 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift @@ -74,7 +74,7 @@ struct LiveTVChannelItemElement: View { GeometryReader { gp in VStack { - ImageView(channel.getPrimaryImage(maxWidth: 192)) + ImageView(channel.imageSource(.primary, maxWidth: 192)) .aspectRatio(contentMode: .fit) .frame(width: 192, alignment: .center) } diff --git a/Swiftfin tvOS/Views/LiveTVChannelsView.swift b/Swiftfin tvOS/Views/LiveTVChannelsView.swift index f2aa5efa..036c1f12 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelsView.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelsView.swift @@ -15,7 +15,7 @@ typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) struct LiveTVChannelsView: View { @EnvironmentObject - var router: LiveTVChannelsCoordinator.Router + private var router: LiveTVChannelsCoordinator.Router @StateObject var viewModel = LiveTVChannelsViewModel() diff --git a/Swiftfin tvOS/Views/LiveTVProgramsView.swift b/Swiftfin tvOS/Views/LiveTVProgramsView.swift index 6fadf822..f40d3e89 100644 --- a/Swiftfin tvOS/Views/LiveTVProgramsView.swift +++ b/Swiftfin tvOS/Views/LiveTVProgramsView.swift @@ -11,7 +11,7 @@ import SwiftUI struct LiveTVProgramsView: View { @EnvironmentObject - var programsRouter: LiveTVProgramsCoordinator.Router + private var programsRouter: LiveTVProgramsCoordinator.Router @StateObject var viewModel = LiveTVProgramsViewModel() diff --git a/Swiftfin tvOS/Views/MovieLibrariesView.swift b/Swiftfin tvOS/Views/MovieLibrariesView.swift index b378a9d8..f989c222 100644 --- a/Swiftfin tvOS/Views/MovieLibrariesView.swift +++ b/Swiftfin tvOS/Views/MovieLibrariesView.swift @@ -12,7 +12,7 @@ import SwiftUICollection struct MovieLibrariesView: View { @EnvironmentObject - var movieLibrariesRouter: MovieLibrariesCoordinator.Router + private var movieLibrariesRouter: MovieLibrariesCoordinator.Router @StateObject var viewModel: MovieLibrariesViewModel var title: String diff --git a/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift b/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift deleted file mode 100644 index 50a4324b..00000000 --- a/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct NextUpCard: View { - - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - let item: BaseItemDto - - var body: some View { - VStack(alignment: .leading) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - if item.itemType == .episode { - ImageView(item.getSeriesBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } else { - ImageView(item.getBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } - } - .buttonStyle(CardButtonStyle()) - .padding(.top) - - VStack(alignment: .leading) { - Text("\(item.seriesName ?? item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - - if item.itemType == .episode { - Text(item.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/NextUpView/NextUpView.swift b/Swiftfin tvOS/Views/NextUpView/NextUpView.swift deleted file mode 100644 index a814f713..00000000 --- a/Swiftfin tvOS/Views/NextUpView/NextUpView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import Stinsen -import SwiftUI - -struct NextUpView: View { - var items: [BaseItemDto] - - var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve() - - var body: some View { - VStack(alignment: .leading) { - - L10n.nextUp.text - .font(.title3) - .fontWeight(.semibold) - .padding(.leading, 50) - - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(items, id: \.id) { item in - NextUpCard(item: item) - } - } - .padding(.horizontal, 50) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ServerListView.swift b/Swiftfin tvOS/Views/ServerListView.swift index 81f3dbbf..71b23731 100644 --- a/Swiftfin tvOS/Views/ServerListView.swift +++ b/Swiftfin tvOS/Views/ServerListView.swift @@ -12,7 +12,7 @@ import SwiftUI struct ServerListView: View { @EnvironmentObject - var serverListRouter: ServerListCoordinator.Router + private var serverListRouter: ServerListCoordinator.Router @ObservedObject var viewModel: ServerListViewModel diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift index 5972c47a..ccbce55f 100644 --- a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -14,7 +14,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject - var settingsRouter: SettingsCoordinator.Router + private var settingsRouter: SettingsCoordinator.Router @ObservedObject var viewModel: SettingsViewModel @@ -28,8 +28,6 @@ struct SettingsView: View { var downActionShowsMenu @Default(.confirmClose) var confirmClose - @Default(.tvOSCinematicViews) - var tvOSCinematicViews @Default(.showPosterLabels) var showPosterLabels @Default(.resumeOffset) @@ -124,12 +122,6 @@ struct SettingsView: View { } } - Section { - Toggle(L10n.cinematicViews, isOn: $tvOSCinematicViews) - } header: { - L10n.appearance.text - } - Section(header: L10n.accessibility.text) { Button { settingsRouter.route(to: \.customizeViewsSettings) diff --git a/Swiftfin tvOS/Views/TVLibrariesView.swift b/Swiftfin tvOS/Views/TVLibrariesView.swift index 5fb17aae..5e5179c4 100644 --- a/Swiftfin tvOS/Views/TVLibrariesView.swift +++ b/Swiftfin tvOS/Views/TVLibrariesView.swift @@ -12,7 +12,7 @@ import SwiftUICollection struct TVLibrariesView: View { @EnvironmentObject - var tvLibrariesRouter: TVLibrariesCoordinator.Router + private var tvLibrariesRouter: TVLibrariesCoordinator.Router @StateObject var viewModel: TVLibrariesViewModel var title: String diff --git a/Swiftfin tvOS/Views/UserListView.swift b/Swiftfin tvOS/Views/UserListView.swift index 4e42bd7f..447baa55 100644 --- a/Swiftfin tvOS/Views/UserListView.swift +++ b/Swiftfin tvOS/Views/UserListView.swift @@ -11,7 +11,7 @@ import SwiftUI struct UserListView: View { @EnvironmentObject - var userListRouter: UserListCoordinator.Router + private var userListRouter: UserListCoordinator.Router @ObservedObject var viewModel: UserListViewModel diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift index 3839a716..84ef35db 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift @@ -149,6 +149,7 @@ struct tvOSLiveTVOverlay_Previews: PreviewProvider { hlsStreamURL: URL(string: "www.apple.com")!, streamType: .direct, response: PlaybackInfoResponse(), + videoStream: MediaStream(), audioStreams: [MediaStream(displayTitle: "English", index: -1)], subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], chapters: [], diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift index 1857cbe5..d4a2e433 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift @@ -149,6 +149,7 @@ struct tvOSVLCOverlay_Previews: PreviewProvider { hlsStreamURL: URL(string: "www.apple.com")!, streamType: .direct, response: PlaybackInfoResponse(), + videoStream: MediaStream(), audioStreams: [MediaStream(displayTitle: "English", index: -1)], subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], chapters: [], diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 323c071c..6ea201a1 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -12,106 +12,66 @@ 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; }; 09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; }; - 53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A16268B919A003024C9 /* SeriesItemView.swift */; }; - 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53116A18268B947A003024C9 /* PlainLinkButton.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */; }; - 531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690EE267ABF72005D8AB9 /* NextUpView.swift */; }; 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */; }; - 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; }; - 531ABF6C2671F5CC00C0FE20 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 628B95212670CABD0091AF3B /* WidgetKit.framework */; }; 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; }; 5321753E2671DE9C005491E6 /* Typings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* Typings.swift */; }; 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */; }; - 53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */; }; - 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */; }; - 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53272538268C20100035FBF1 /* EpisodeItemView.swift */; }; 53352571265EA0A0006CCA86 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 53352570265EA0A0006CCA86 /* Introspect */; }; 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5338F74D263B61370014BF09 /* ConnectToServerView.swift */; }; 534D4FF026A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF126A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; - 534D4FF226A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEE26A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF326A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FE726A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF426A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FE726A7D7CC000A7A48 /* Localizable.strings */; }; - 534D4FF526A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FE726A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF626A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEB26A7D7CC000A7A48 /* Localizable.strings */; }; 534D4FF726A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEB26A7D7CC000A7A48 /* Localizable.strings */; }; - 534D4FF826A7D7CC000A7A48 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 534D4FEB26A7D7CC000A7A48 /* Localizable.strings */; }; 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870622669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift */; }; 535870672669D21700D05A09 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870662669D21700D05A09 /* Assets.xcassets */; }; 5358706A2669D21700D05A09 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 535870692669D21700D05A09 /* Preview Assets.xcassets */; }; 5358707E2669D64F00D05A09 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; 535870912669D7A800D05A09 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 535870902669D7A800D05A09 /* Introspect */; }; - 535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; }; - 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338B22660A07800A81A2A /* LazyView.swift */; }; - 535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; }; 535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; - 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; }; 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* Typings.swift */; }; 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; }; 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; }; 53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; }; - 53649AB3269D3F5B00A2D8B7 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53649AB0269CFB1900A2D8B7 /* LogManager.swift */; }; 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */; }; 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */; }; - 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D73267BA8170004248C /* BackgroundManager.swift */; }; - 536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */; }; 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; }; - 536D3D79267BD5D00004248C /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; }; - 536D3D7D267BD5F90004248C /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 536D3D7C267BD5F90004248C /* ActivityIndicator */; }; - 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D7E267BDF100004248C /* LatestMediaView.swift */; }; 536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D80267BDFC60004248C /* PortraitItemElement.swift */; }; - 536D3D84267BEA550004248C /* ParallaxView in Frameworks */ = {isa = PBXBuildFile; productRef = 536D3D83267BEA550004248C /* ParallaxView */; }; - 536D3D88267C17350004248C /* PublicUserButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D87267C17350004248C /* PublicUserButton.swift */; }; 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */; }; 5377CBF9263B596B003A4E83 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; 5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */; }; - 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276D263C25100035E14B /* ContinueWatchingView.swift */; }; - 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; }; 53913BEF26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; 53913BF026D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; - 53913BF126D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; 53913BF226D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BCC26D323FE00EB3286 /* Localizable.strings */; }; 53913BF326D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BCC26D323FE00EB3286 /* Localizable.strings */; }; - 53913BF426D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BCC26D323FE00EB3286 /* Localizable.strings */; }; 53913BF526D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BCF26D323FE00EB3286 /* Localizable.strings */; }; 53913BF626D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BCF26D323FE00EB3286 /* Localizable.strings */; }; - 53913BF726D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BCF26D323FE00EB3286 /* Localizable.strings */; }; 53913BF826D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BD226D323FE00EB3286 /* Localizable.strings */; }; 53913BF926D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BD226D323FE00EB3286 /* Localizable.strings */; }; - 53913BFA26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BD226D323FE00EB3286 /* Localizable.strings */; }; 53913BFB26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BD526D323FE00EB3286 /* Localizable.strings */; }; 53913BFC26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BD526D323FE00EB3286 /* Localizable.strings */; }; - 53913BFD26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BD526D323FE00EB3286 /* Localizable.strings */; }; 53913BFE26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BD826D323FE00EB3286 /* Localizable.strings */; }; 53913BFF26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BD826D323FE00EB3286 /* Localizable.strings */; }; - 53913C0026D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BD826D323FE00EB3286 /* Localizable.strings */; }; 53913C0126D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BDB26D323FE00EB3286 /* Localizable.strings */; }; 53913C0226D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BDB26D323FE00EB3286 /* Localizable.strings */; }; - 53913C0326D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BDB26D323FE00EB3286 /* Localizable.strings */; }; 53913C0426D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BDE26D323FE00EB3286 /* Localizable.strings */; }; 53913C0526D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BDE26D323FE00EB3286 /* Localizable.strings */; }; - 53913C0626D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BDE26D323FE00EB3286 /* Localizable.strings */; }; 53913C0726D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BE126D323FE00EB3286 /* Localizable.strings */; }; 53913C0826D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BE126D323FE00EB3286 /* Localizable.strings */; }; - 53913C0926D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BE126D323FE00EB3286 /* Localizable.strings */; }; 53913C0A26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BE426D323FE00EB3286 /* Localizable.strings */; }; 53913C0B26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BE426D323FE00EB3286 /* Localizable.strings */; }; - 53913C0C26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BE426D323FE00EB3286 /* Localizable.strings */; }; 53913C0D26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BE726D323FE00EB3286 /* Localizable.strings */; }; 53913C0E26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BE726D323FE00EB3286 /* Localizable.strings */; }; - 53913C0F26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BE726D323FE00EB3286 /* Localizable.strings */; }; 53913C1026D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BEA26D323FE00EB3286 /* Localizable.strings */; }; 53913C1126D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BEA26D323FE00EB3286 /* Localizable.strings */; }; - 53913C1226D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BEA26D323FE00EB3286 /* Localizable.strings */; }; 53913C1326D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BED26D323FE00EB3286 /* Localizable.strings */; }; 53913C1426D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BED26D323FE00EB3286 /* Localizable.strings */; }; - 53913C1526D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BED26D323FE00EB3286 /* Localizable.strings */; }; 5398514526B64DA100101B49 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5398514426B64DA100101B49 /* SettingsView.swift */; }; - 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; }; - 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; }; 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */; }; 53A83C33268A309300DF3D92 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A83C32268A309300DF3D92 /* LibraryView.swift */; }; 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53ABFDDB267972BF00886593 /* TVServices.framework */; }; @@ -122,27 +82,21 @@ 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5722678C32A00530A6E /* HomeViewModel.swift */; }; 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ABFDEA2679753200886593 /* ConnectToServerView.swift */; }; 53ABFDED26799D7700886593 /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 53ABFDEC26799D7700886593 /* ActivityIndicator */; }; - 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A3F268A49C2002ABD4E /* ItemView.swift */; }; - 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */; }; 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; }; 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; }; 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; }; - 53F866442687A45F00DCD1D7 /* PortraitItemButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F866432687A45F00DCD1D7 /* PortraitItemButton.swift */; }; - 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */; }; + 53F866442687A45F00DCD1D7 /* PortraitPosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F866432687A45F00DCD1D7 /* PortraitPosterButton.swift */; }; 5D1603FC278A3D5800D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; 5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; - 5D1603FE278A40DC00D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; 5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */; }; 5D32EA12278C95E30020E292 /* VLCPlayer+subtitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */; }; 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */; }; 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213388F265F83A900A81A2A /* LibraryListView.swift */; }; 621338932660107500A81A2A /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; - 621338B32660A07800A81A2A /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338B22660A07800A81A2A /* LazyView.swift */; }; 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */; }; 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */; }; - 6220D0AF26D5EABE00B8E046 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */; }; 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */; }; 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */; }; 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */; }; @@ -151,20 +105,15 @@ 6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */; }; 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 6220D0C826D63F3700B8E046 /* Stinsen */; }; 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */; }; - 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; }; 62400C4B287ED19600F6AD3D /* UDPBroadcast.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */; }; 62400C4C287ED19600F6AD3D /* UDPBroadcast.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 637FCAF3287B5B2600C0A353 /* UDPBroadcast.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; }; - 62553429282190A00087FE20 /* PanDirectionGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62553428282190A00087FE20 /* PanDirectionGestureRecognizer.swift */; }; - 625CB56F2678C23300530A6E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB56E2678C23300530A6E /* HomeView.swift */; }; 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5722678C32A00530A6E /* HomeViewModel.swift */; }; 625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* LibraryListViewModel.swift */; }; 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */; }; 625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 625CB5792678C4A400530A6E /* ActivityIndicator */; }; - 6264E88A27384A6F0081A12A /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; 6264E88C273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; 6264E88D273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; - 6264E88E273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; }; 62666DF827E5012C00EC0ECD /* MobileVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */; }; @@ -197,24 +146,11 @@ 62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2D27E5021400EC0ECD /* Security.framework */; }; 62666E3027E5021800EC0ECD /* VideoToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2F27E5021800EC0ECD /* VideoToolbox.framework */; }; 62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E3127E5021E00EC0ECD /* UIKit.framework */; }; - 62666E3427E5027F00EC0ECD /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62666E3327E5027F00EC0ECD /* Defaults+Workaround.swift */; }; - 62666E3527E5027F00EC0ECD /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62666E3327E5027F00EC0ECD /* Defaults+Workaround.swift */; }; - 62666E3627E5027F00EC0ECD /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62666E3327E5027F00EC0ECD /* Defaults+Workaround.swift */; }; 62666E3927E502CE00EC0ECD /* SwizzleSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 62666E3827E502CE00EC0ECD /* SwizzleSwift */; }; 62666E3E27E503FA00EC0ECD /* MediaAccessibility.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4BE267D40E4000E2F71 /* MediaAccessibility.framework */; }; 62666E3F27E5040300EC0ECD /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4C6267D40F4000E2F71 /* SystemConfiguration.framework */; }; 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; - 6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; }; - 6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D92671138200A7371D /* ImageExtensions.swift */; }; - 6267B3DB2671139400A7371D /* ImageExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D92671138200A7371D /* ImageExtensions.swift */; }; - 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D92671138200A7371D /* ImageExtensions.swift */; }; - 628B95242670CABD0091AF3B /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 628B95232670CABD0091AF3B /* SwiftUI.framework */; }; - 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628B95262670CABD0091AF3B /* NextUpWidget.swift */; }; - 628B95292670CABE0091AF3B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 628B95282670CABE0091AF3B /* Assets.xcassets */; }; - 628B952D2670CABE0091AF3B /* Swiftfin Widget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 628B95202670CABD0091AF3B /* Swiftfin Widget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628B95362670CB800091AF3B /* JellyfinWidget.swift */; }; - 628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; 62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 62C29E9B26D0FE4200C1D2E7 /* Stinsen */; }; 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29E9E26D1016600C1D2E7 /* iOSMainCoordinator.swift */; }; 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA026D102A500C1D2E7 /* iOSMainTabCoordinator.swift */; }; @@ -224,7 +160,6 @@ 62C83B08288C6A630004ED0C /* FontPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C83B07288C6A630004ED0C /* FontPicker.swift */; }; 62E1DCC3273CE19800C9AE76 /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */; }; 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */; }; - 62E1DCC5273CE19800C9AE76 /* URLExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */; }; 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */; }; 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; }; 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; }; @@ -245,7 +180,6 @@ 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* ItemViewModel.swift */; }; 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; - 62EC353226766849000E9F2D /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352E267666A5000E9F2D /* SessionManager.swift */; }; 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */; }; 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */; }; 631759CF2879DB6A00A621AD /* PublicUserSignInCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631759CE2879DB6A00A621AD /* PublicUserSignInCellView.swift */; }; @@ -280,7 +214,6 @@ C4BE0764271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE0762271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift */; }; C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */; }; C4BE076A271FC164003F4AD1 /* TVLibrariesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE0768271FC164003F4AD1 /* TVLibrariesView.swift */; }; - C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE076D2720FEA8003F4AD1 /* PortraitItemElement.swift */; }; C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */; }; @@ -301,41 +234,42 @@ E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; }; E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; }; - E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; }; E103A6A0278A7E4500820EC7 /* UICinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A69F278A7E4500820EC7 /* UICinematicBackgroundView.swift */; }; E103A6A3278A7EC400820EC7 /* CinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A6A2278A7EC400820EC7 /* CinematicBackgroundView.swift */; }; E103A6A5278A82E500820EC7 /* HomeCinematicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A6A4278A82E500820EC7 /* HomeCinematicView.swift */; }; E103A6A7278AB6D700820EC7 /* CinematicResumeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A6A6278AB6D700820EC7 /* CinematicResumeCardView.swift */; }; E103A6A9278AB6FF00820EC7 /* CinematicNextUpCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A6A8278AB6FF00820EC7 /* CinematicNextUpCardView.swift */; }; E1047E2027E584AF00CB0D4A /* BlurHashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E1F27E584AF00CB0D4A /* BlurHashView.swift */; }; - E1047E2127E584AF00CB0D4A /* BlurHashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E1F27E584AF00CB0D4A /* BlurHashView.swift */; }; E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* InitialFailureView.swift */; }; - E1047E2427E5880000CB0D4A /* InitialFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* InitialFailureView.swift */; }; E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; - E107BB972788104100354E07 /* CinematicCollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB952788104100354E07 /* CinematicCollectionItemView.swift */; }; - E10C0941278B8DAB009DBF93 /* PortraitItemSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10C0940278B8DAB009DBF93 /* PortraitItemSize.swift */; }; - E10C0942278B8DAB009DBF93 /* PortraitItemSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10C0940278B8DAB009DBF93 /* PortraitItemSize.swift */; }; - E10D87DA2784E4F100BD264C /* ItemViewDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */; }; - E10D87DC2784EC5200BD264C /* EpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */; }; - E10D87DE278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; - E10D87DF278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; - E10D87E0278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; + E10D87DC2784EC5200BD264C /* SeriesEpisodesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DB2784EC5200BD264C /* SeriesEpisodesView.swift */; }; E10D87E227852FD000BD264C /* EpisodesRowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowManager.swift */; }; E10D87E327852FD000BD264C /* EpisodesRowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowManager.swift */; }; E10EAA45277BB646000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA44277BB646000269ED /* JellyfinAPI */; }; - E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA46277BB670000269ED /* JellyfinAPI */; }; E10EAA4D277BB716000269ED /* Sliders in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA4C277BB716000269ED /* Sliders */; }; E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; - E10EAA51277BBCC4000269ED /* CGSizeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */; }; E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; }; E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; }; E1101177281B1E8A006A3584 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = E1101176281B1E8A006A3584 /* Puppy */; }; E111DE222790BB46008118A3 /* DetectBottomScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */; }; + E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; }; + E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; }; + E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */; }; + E11895AA289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */; }; + E11895AC289383EE0042947B /* NavBarOffsetModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895AB289383EE0042947B /* NavBarOffsetModifier.swift */; }; + E11895AF2893840F0042947B /* NavBarOffsetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895AE2893840F0042947B /* NavBarOffsetView.swift */; }; + E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */; }; + E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */; }; E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; - E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; + E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; }; + E11CEB8B28998552003E74C7 /* iOSViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8A28998552003E74C7 /* iOSViewExtensions.swift */; }; + E11CEB8D28999B4A003E74C7 /* FontExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* FontExtensions.swift */; }; + E11CEB8E28999B4A003E74C7 /* FontExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* FontExtensions.swift */; }; + E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */; }; + E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */; }; E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */; }; E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */; }; E11D83AF278FA998006E9776 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E11D83AE278FA998006E9776 /* NukeUI */; }; @@ -347,11 +281,12 @@ E126F741278A656C00A522BF /* ServerStreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E126F740278A656C00A522BF /* ServerStreamType.swift */; }; E126F742278A656C00A522BF /* ServerStreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E126F740278A656C00A522BF /* ServerStreamType.swift */; }; E1347DB2279E3C6200BC6161 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = E1347DB1279E3C6200BC6161 /* Puppy */; }; - E1347DB4279E3C9E00BC6161 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = E1347DB3279E3C9E00BC6161 /* Puppy */; }; E1347DB6279E3CA500BC6161 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = E1347DB5279E3CA500BC6161 /* Puppy */; }; E1361DA7278FA7A300BEC523 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1361DA6278FA7A300BEC523 /* NukeUI */; }; E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1384943278036C70024FB48 /* VLCPlayerViewController.swift */; }; E13849452780370B0024FB48 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; + E1399474289B1EA900401ABC /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1399473289B1EA900401ABC /* Defaults+Workaround.swift */; }; + E1399475289B1EA900401ABC /* Defaults+Workaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1399473289B1EA900401ABC /* Defaults+Workaround.swift */; }; E13AD72E2798BC8D00FDCEE8 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13AD72D2798BC8D00FDCEE8 /* NativePlayerViewController.swift */; }; E13AD7302798C60F00FDCEE8 /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13AD72F2798C60F00FDCEE8 /* NativePlayerViewController.swift */; }; E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */; }; @@ -360,15 +295,10 @@ E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3C52716499E009D4DAF /* CoreStore */; }; E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */; }; E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */; }; - E13DD3CA27164B80009D4DAF /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C127164941009D4DAF /* SwiftfinStore.swift */; }; - E13DD3CB27164BA8009D4DAF /* UIDeviceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */; }; E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3CC27164CA7009D4DAF /* CoreStore */; }; - E13DD3CF27164E1F009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3CE27164E1F009D4DAF /* CoreStore */; }; E13DD3D327168E65009D4DAF /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3D227168E65009D4DAF /* Defaults */; }; E13DD3D5271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3D4271693CD009D4DAF /* SwiftfinStoreDefaults.swift */; }; E13DD3D6271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3D4271693CD009D4DAF /* SwiftfinStoreDefaults.swift */; }; - E13DD3D7271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3D4271693CD009D4DAF /* SwiftfinStoreDefaults.swift */; }; - E13DD3DD27175CE3009D4DAF /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3DC27175CE3009D4DAF /* Defaults */; }; E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */; }; E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */; }; E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3E427177D15009D4DAF /* ServerListView.swift */; }; @@ -379,37 +309,87 @@ E13DD3F027178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3EE27178F87009D4DAF /* SwiftfinNotificationCenter.swift */; }; E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F127179378009D4DAF /* UserSignInCoordinator.swift */; }; E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F4271793BB009D4DAF /* UserSignInView.swift */; }; - E13DD3F72717E87D009D4DAF /* SwiftfinNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3EE27178F87009D4DAF /* SwiftfinNotificationCenter.swift */; }; E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* UserListViewModel.swift */; }; E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* UserListViewModel.swift */; }; E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3FB2717EAE8009D4DAF /* UserListView.swift */; }; E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; - E13F26AF278754E300DF4761 /* CinematicSeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F26AE278754E300DF4761 /* CinematicSeriesItemView.swift */; }; - E13F26B12787589300DF4761 /* CinematicSeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F26B02787589300DF4761 /* CinematicSeasonItemView.swift */; }; - E14B4141279354770016CBE5 /* LocalizedLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14B4140279354770016CBE5 /* LocalizedLookup.swift */; }; - E14B4142279354770016CBE5 /* LocalizedLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14B4140279354770016CBE5 /* LocalizedLookup.swift */; }; - E14B4143279354770016CBE5 /* LocalizedLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14B4140279354770016CBE5 /* LocalizedLookup.swift */; }; - E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */; }; - E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */; }; + E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546776289AF46E00087E35 /* CollectionItemView.swift */; }; + E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546779289AF48200087E35 /* CollectionItemContentView.swift */; }; + E168BD10289A4162001A6922 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD08289A4162001A6922 /* HomeView.swift */; }; + E168BD11289A4162001A6922 /* HomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD09289A4162001A6922 /* HomeContentView.swift */; }; + E168BD12289A4162001A6922 /* ContinueWatchingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0C289A4162001A6922 /* ContinueWatchingCard.swift */; }; + E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */; }; + E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */; }; + E168BD15289A4162001A6922 /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0F289A4162001A6922 /* HomeErrorView.swift */; }; E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; - E176DE6D278E30D2001EFD8D /* EpisodeRowCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E176DE6C278E30D2001EFD8D /* EpisodeRowCard.swift */; }; + E176DE6D278E30D2001EFD8D /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E176DE6C278E30D2001EFD8D /* EpisodeCard.swift */; }; E176DE70278E369F001EFD8D /* MissingItemsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */; }; E178857D278037FD0094FBCF /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E178857C278037FD0094FBCF /* JellyfinAPI */; }; E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859A2780F1F40094FBCF /* tvOSSlider.swift */; }; E178859E2780F53B0094FBCF /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859D2780F53B0094FBCF /* SliderView.swift */; }; E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */; }; E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17885A3278105170094FBCF /* SFSymbolButton.swift */; }; - E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; }; - E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; }; - E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */; }; - E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */; }; + E184C160288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E184C15F288C5C08000B25BA /* RequestBuilderExtensions.swift */; }; + E184C161288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E184C15F288C5C08000B25BA /* RequestBuilderExtensions.swift */; }; + E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */; }; + E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */; }; + E18E01A9288746AF0022598C /* PortraitPosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A3288746AF0022598C /* PortraitPosterHStack.swift */; }; + E18E01AA288746AF0022598C /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A4288746AF0022598C /* RefreshableScrollView.swift */; }; + E18E01AB288746AF0022598C /* PillHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A5288746AF0022598C /* PillHStack.swift */; }; + E18E01AD288746AF0022598C /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A7288746AF0022598C /* DotHStack.swift */; }; + E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01B6288747230022598C /* iPadOSEpisodeContentView.swift */; }; + E18E01DB288747230022598C /* iPadOSEpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01B7288747230022598C /* iPadOSEpisodeItemView.swift */; }; + E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01B9288747230022598C /* iPadOSCinematicScrollView.swift */; }; + E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01BB288747230022598C /* iPadOSSeriesItemContentView.swift */; }; + E18E01DE288747230022598C /* iPadOSSeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01BC288747230022598C /* iPadOSSeriesItemView.swift */; }; + E18E01DF288747230022598C /* iPadOSMovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01BE288747230022598C /* iPadOSMovieItemView.swift */; }; + E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01BF288747230022598C /* iPadOSMovieItemContentView.swift */; }; + E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01C2288747230022598C /* EpisodeItemContentView.swift */; }; + E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01C3288747230022598C /* EpisodeItemView.swift */; }; + E18E01E3288747230022598C /* CompactPortraitScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01C5288747230022598C /* CompactPortraitScrollView.swift */; }; + E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01C6288747230022598C /* CompactLogoScrollView.swift */; }; + E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01C7288747230022598C /* CinematicScrollView.swift */; }; + E18E01E6288747230022598C /* CollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01C9288747230022598C /* CollectionItemView.swift */; }; + E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01CA288747230022598C /* CollectionItemContentView.swift */; }; + E18E01E8288747230022598C /* SeriesItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01CC288747230022598C /* SeriesItemContentView.swift */; }; + E18E01E9288747230022598C /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01CD288747230022598C /* SeriesItemView.swift */; }; + E18E01EA288747230022598C /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01CF288747230022598C /* MovieItemView.swift */; }; + E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D0288747230022598C /* MovieItemContentView.swift */; }; + E18E01EE288747230022598C /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D5288747230022598C /* AboutView.swift */; }; + E18E01EF288747230022598C /* ListDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D6288747230022598C /* ListDetailsView.swift */; }; + E18E01F0288747230022598C /* AttributeHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D7288747230022598C /* AttributeHStack.swift */; }; + E18E01F1288747230022598C /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D8288747230022598C /* PlayButton.swift */; }; + E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D9288747230022598C /* ActionButtonHStack.swift */; }; + E18E01FA288747580022598C /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01F3288747580022598C /* AboutAppView.swift */; }; + E18E0204288749200022598C /* Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* Divider.swift */; }; + E18E0205288749200022598C /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0200288749200022598C /* AppIcon.swift */; }; + E18E0206288749200022598C /* AttributeFillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0201288749200022598C /* AttributeFillView.swift */; }; + E18E0207288749200022598C /* AttributeOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeOutlineView.swift */; }; + E18E0208288749200022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; }; + E18E021A2887492B0022598C /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0200288749200022598C /* AppIcon.swift */; }; + E18E021B2887492B0022598C /* SearchBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DE4BD1267098F300739748 /* SearchBarView.swift */; }; + E18E021C2887492B0022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; }; + E18E021D2887492B0022598C /* AttributeOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeOutlineView.swift */; }; + E18E021E2887492B0022598C /* Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* Divider.swift */; }; + E18E021F2887492B0022598C /* InitialFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* InitialFailureView.swift */; }; + E18E02202887492B0022598C /* AttributeFillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0201288749200022598C /* AttributeFillView.swift */; }; + E18E02212887492B0022598C /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; }; + E18E02222887492B0022598C /* SearchablePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624C21742685CF60007F1390 /* SearchablePickerView.swift */; }; + E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; + E18E02242887492B0022598C /* BlurHashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E1F27E584AF00CB0D4A /* BlurHashView.swift */; }; + E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; + E18E023A288749540022598C /* UIScrollViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0239288749540022598C /* UIScrollViewExtensions.swift */; }; + E18E023C288749540022598C /* UIScrollViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0239288749540022598C /* UIScrollViewExtensions.swift */; }; E19169CE272514760085832A /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; E19169CF272514760085832A /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; - E19169D0272514760085832A /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; - E193D4D827193CAC00900D82 /* PortraitImageStackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */; }; - E193D4D927193CAC00900D82 /* PortraitImageStackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */; }; + E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */; }; + E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */; }; + E1937A3E288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3D288F0D3D00CB80AA /* UIScreenExtensions.swift */; }; + E1937A3F288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A3D288F0D3D00CB80AA /* UIScreenExtensions.swift */; }; + E1937A61288F32DB00CB80AA /* Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A60288F32DB00CB80AA /* Poster.swift */; }; + E1937A62288F32DB00CB80AA /* Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1937A60288F32DB00CB80AA /* Poster.swift */; }; E193D4DB27193CCA00900D82 /* PillStackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D4DA27193CCA00900D82 /* PillStackable.swift */; }; E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D4DA27193CCA00900D82 /* PillStackable.swift */; }; E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */; }; @@ -431,40 +411,49 @@ E193D5502719430400900D82 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D54F2719430400900D82 /* ServerDetailView.swift */; }; E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D552271943D500900D82 /* tvOSMainTabCoordinator.swift */; }; - E1A2C151279A7008005EC829 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C150279A7008005EC829 /* AboutView.swift */; }; + E19E550428972B97003CE330 /* ColourSpace.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54F828972B97003CE330 /* ColourSpace.swift */; }; + E19E550528972B97003CE330 /* ColourSpace.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54F828972B97003CE330 /* ColourSpace.swift */; }; + E19E550628972B97003CE330 /* BlurHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54F928972B97003CE330 /* BlurHash.swift */; }; + E19E550728972B97003CE330 /* BlurHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54F928972B97003CE330 /* BlurHash.swift */; }; + E19E550828972B97003CE330 /* StringCoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FA28972B97003CE330 /* StringCoding.swift */; }; + E19E550928972B97003CE330 /* StringCoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FA28972B97003CE330 /* StringCoding.swift */; }; + E19E550A28972B97003CE330 /* FromString.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FB28972B97003CE330 /* FromString.swift */; }; + E19E550B28972B97003CE330 /* FromString.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FB28972B97003CE330 /* FromString.swift */; }; + E19E550C28972B97003CE330 /* EscapeSequences.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FC28972B97003CE330 /* EscapeSequences.swift */; }; + E19E550D28972B97003CE330 /* EscapeSequences.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FC28972B97003CE330 /* EscapeSequences.swift */; }; + E19E550E28972B97003CE330 /* ColourProbes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FD28972B97003CE330 /* ColourProbes.swift */; }; + E19E550F28972B97003CE330 /* ColourProbes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FD28972B97003CE330 /* ColourProbes.swift */; }; + E19E551028972B97003CE330 /* ToString.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FE28972B97003CE330 /* ToString.swift */; }; + E19E551128972B97003CE330 /* ToString.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FE28972B97003CE330 /* ToString.swift */; }; + E19E551228972B97003CE330 /* ToUIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FF28972B97003CE330 /* ToUIImage.swift */; }; + E19E551328972B97003CE330 /* ToUIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E54FF28972B97003CE330 /* ToUIImage.swift */; }; + E19E551428972B97003CE330 /* FromUIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E550028972B97003CE330 /* FromUIImage.swift */; }; + E19E551528972B97003CE330 /* FromUIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E550028972B97003CE330 /* FromUIImage.swift */; }; + E19E551828972B97003CE330 /* Generation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E550228972B97003CE330 /* Generation.swift */; }; + E19E551928972B97003CE330 /* Generation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E550228972B97003CE330 /* Generation.swift */; }; + E19E551A28972B97003CE330 /* TupleMaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E550328972B97003CE330 /* TupleMaths.swift */; }; + E19E551B28972B97003CE330 /* TupleMaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E550328972B97003CE330 /* TupleMaths.swift */; }; + E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; }; + E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A16C9C2889AF1E00EA4679 /* AboutView.swift */; }; + E1A16CA1288A7CFD00EA4679 /* AboutViewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A16CA0288A7CFD00EA4679 /* AboutViewCard.swift */; }; E1A2C154279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */; }; - E1A2C155279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */; }; E1A2C156279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */; }; E1A2C158279A7D76005EC829 /* BundleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C157279A7D76005EC829 /* BundleExtensions.swift */; }; - E1A2C159279A7D76005EC829 /* BundleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C157279A7D76005EC829 /* BundleExtensions.swift */; }; E1A2C15A279A7D76005EC829 /* BundleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C157279A7D76005EC829 /* BundleExtensions.swift */; }; - E1A2C15C279A7D9F005EC829 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15B279A7D9F005EC829 /* AppIcon.swift */; }; - E1A2C15D279A7D9F005EC829 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15B279A7D9F005EC829 /* AppIcon.swift */; }; - E1A2C15E279A7D9F005EC829 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15B279A7D9F005EC829 /* AppIcon.swift */; }; - E1A2C160279A7DCA005EC829 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15F279A7DCA005EC829 /* AboutView.swift */; }; - E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButtonView.swift */; }; + E1A2C160279A7DCA005EC829 /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15F279A7DCA005EC829 /* AboutAppView.swift */; }; + E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButton.swift */; }; E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; E1AA33202782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; - E1AA33222782648000F6439C /* OverlaySliderColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA33212782648000F6439C /* OverlaySliderColor.swift */; }; - E1AA33232782648000F6439C /* OverlaySliderColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA33212782648000F6439C /* OverlaySliderColor.swift */; }; - E1AA332427829B5200F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; - E1AD105426D97161003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; }; - E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */; }; - E1AD105926D9A543003E4A08 /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338B22660A07800A81A2A /* LazyView.swift */; }; - E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */; }; E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; }; - E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */; }; E1AE8E7C2789135A00FBDDAA /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E1AE8E7B2789135A00FBDDAA /* Nuke */; }; E1AE8E7E2789136D00FBDDAA /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E1AE8E7D2789136D00FBDDAA /* Nuke */; }; E1B2AB9928808E150072B3B9 /* GoogleCast.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */; }; E1B59FD52786ADE500A5287E /* ContinueWatchingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */; }; - E1B59FD92786AE4600A5287E /* NextUpCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B59FD82786AE4600A5287E /* NextUpCard.swift */; }; E1B6DCE8271A23780015B715 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE7271A23780015B715 /* CombineExt */; }; E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE9271A23880015B715 /* SwiftyJSON */; }; E1BDE359278E9ED2004E4022 /* MissingItemsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */; }; - E1BDE35B278EA3A3004E4022 /* EpisodesRowCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDE35A278EA3A3004E4022 /* EpisodesRowCard.swift */; }; E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */; }; E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */; }; @@ -477,6 +466,24 @@ E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */; }; E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */; }; E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */; }; + E1C925F428875037002A7A66 /* ItemViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F328875037002A7A66 /* ItemViewType.swift */; }; + E1C925F528875037002A7A66 /* ItemViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F328875037002A7A66 /* ItemViewType.swift */; }; + E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */; }; + E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F828875647002A7A66 /* LatestInLibraryView.swift */; }; + E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925FB2887565C002A7A66 /* MovieItemView.swift */; }; + E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925FC2887565C002A7A66 /* MovieItemContentView.swift */; }; + E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925FE2887565C002A7A66 /* CinematicScrollView.swift */; }; + E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926012887565C002A7A66 /* AttributeHStack.swift */; }; + E1C926102887565C002A7A66 /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926022887565C002A7A66 /* PlayButton.swift */; }; + E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926032887565C002A7A66 /* ActionButtonHStack.swift */; }; + E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926052887565C002A7A66 /* SeriesItemContentView.swift */; }; + E1C926132887565C002A7A66 /* SeriesEpisodesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926072887565C002A7A66 /* SeriesEpisodesView.swift */; }; + E1C926142887565C002A7A66 /* FocusGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926082887565C002A7A66 /* FocusGuide.swift */; }; + E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926092887565C002A7A66 /* EpisodeCard.swift */; }; + E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9260A2887565C002A7A66 /* SeriesItemView.swift */; }; + E1C9261A288756BD002A7A66 /* PortraitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92617288756BD002A7A66 /* PortraitButton.swift */; }; + E1C9261B288756BD002A7A66 /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92618288756BD002A7A66 /* DotHStack.swift */; }; + E1C9261C288756BD002A7A66 /* PortraitPosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92619288756BD002A7A66 /* PortraitPosterHStack.swift */; }; E1CEFBF527914C7700F60429 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */; }; E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */; }; E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */; }; @@ -490,22 +497,11 @@ E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF862719D27100A11E64 /* Bitrates.swift */; }; E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */; }; E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* BasicAppSettingsCoordinator.swift */; }; - E1D4BF8C2719F39F00A11E64 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; - E1D4BF8D2719F3A300A11E64 /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */; }; - E1D7E5A827892566009D0EF7 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E1D7E5A727892566009D0EF7 /* Nuke */; }; E1E00A35278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; }; E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; }; - E1E00A37278628A40022235B /* DoubleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E00A34278628A40022235B /* DoubleExtensions.swift */; }; E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */; }; - E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */; }; - E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */; }; - E1E5D53B2783A80900692DFE /* CinematicItemViewTopRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */; }; - E1E5D53E2783B05200692DFE /* CinematicMovieItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */; }; - E1E5D5402783B0C000692DFE /* CinematicItemViewTopRowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */; }; - E1E5D5422783B33900692DFE /* PortraitItemsRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */; }; E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */; }; - E1E5D5462783C28100692DFE /* CinematicItemAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */; }; E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */; }; E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */; }; E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */; }; @@ -515,26 +511,18 @@ E1EBCB44278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB43278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift */; }; E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */; }; E1EBCB4A278BE443009FE6E9 /* ItemOverviewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB43278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift */; }; + E1EF473A289A0F610034046B /* TruncatedTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */; }; E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */; }; + E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */; }; + E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */; }; E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; - E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; /* End PBXBuildFile section */ -/* Begin PBXContainerItemProxy section */ - 6264E888273848760081A12A /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5377CBE9263B596A003A4E83 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 628B951F2670CABD0091AF3B; - remoteInfo = WidgetExtension; - }; -/* End PBXContainerItemProxy section */ - /* Begin PBXCopyFilesBuildPhase section */ 5302F8322658B74800647A2E /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; @@ -574,7 +562,6 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( - 628B952D2670CABE0091AF3B /* Swiftfin Widget.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -584,19 +571,13 @@ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; 09389CC626819B4500AE350E /* VideoPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerModel.swift; sourceTree = ""; }; - 53116A16268B919A003024C9 /* SeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = ""; }; - 53116A18268B947A003024C9 /* PlainLinkButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainLinkButton.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; - 531690EE267ABF72005D8AB9 /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeItemElement.swift; sourceTree = ""; }; 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlainNavigationLinkButton.swift; sourceTree = ""; }; 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileBuilder.swift; sourceTree = ""; }; 531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; - 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewActionButton.swift; sourceTree = ""; }; - 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemView.swift; sourceTree = ""; }; - 53272538268C20100035FBF1 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; 5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; 534D4FE826A7D7CC000A7A48 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = ""; }; 534D4FEC26A7D7CC000A7A48 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = Localizable.strings; sourceTree = ""; }; @@ -627,18 +608,12 @@ 5362E4C8267D40F7000E2F71 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 53649AB0269CFB1900A2D8B7 /* LogManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = ""; }; 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemPersonExtensions.swift; sourceTree = ""; }; - 536D3D73267BA8170004248C /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = ""; }; - 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabViewModel.swift; sourceTree = ""; }; - 536D3D7E267BDF100004248C /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = ""; }; 536D3D80267BDFC60004248C /* PortraitItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemElement.swift; sourceTree = ""; }; - 536D3D87267C17350004248C /* PublicUserButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicUserButton.swift; sourceTree = ""; }; 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Swiftfin iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayerApp.swift; sourceTree = ""; }; 5377CBF8263B596B003A4E83 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 5377CC02263B596B003A4E83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 5389276D263C25100035E14B /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; - 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; 53913BCA26D323FE00EB3286 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = ""; }; 53913BCD26D323FE00EB3286 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Localizable.strings; sourceTree = ""; }; 53913BD026D323FE00EB3286 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = Localizable.strings; sourceTree = ""; }; @@ -658,21 +633,18 @@ 53ABFDDB267972BF00886593 /* TVServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TVServices.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.0.sdk/System/Library/Frameworks/TVServices.framework; sourceTree = DEVELOPER_DIR; }; 53ABFDEA2679753200886593 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; 53CD2A3F268A49C2002ABD4E /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; - 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = ""; }; 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Carthage/Build/MobileVLCKit.xcframework; sourceTree = ""; }; 53DE4BD1267098F300739748 /* SearchBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarView.swift; sourceTree = ""; }; 53DF641D263D9C0600A7CD1A /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = ""; }; 53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; - 53F866432687A45F00DCD1D7 /* PortraitItemButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemButton.swift; sourceTree = ""; }; - 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = ""; }; + 53F866432687A45F00DCD1D7 /* PortraitPosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitPosterButton.swift; sourceTree = ""; }; 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSize.swift; sourceTree = ""; }; 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VLCPlayer+subtitles.swift"; sourceTree = ""; }; 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingSwizzling.swift; sourceTree = ""; }; 6213388F265F83A900A81A2A /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; 621338922660107500A81A2A /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = ""; }; - 621338B22660A07800A81A2A /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; 6220D0B026D5EC9900B8E046 /* SettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCoordinator.swift; sourceTree = ""; }; 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryCoordinator.swift; sourceTree = ""; }; @@ -681,10 +653,7 @@ 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = ""; }; 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSVideoPlayerCoordinator.swift; sourceTree = ""; }; 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = ""; }; - 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.swift; sourceTree = ""; }; 624C21742685CF60007F1390 /* SearchablePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchablePickerView.swift; sourceTree = ""; }; - 62553428282190A00087FE20 /* PanDirectionGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanDirectionGestureRecognizer.swift; sourceTree = ""; }; - 625CB56E2678C23300530A6E /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 625CB5722678C32A00530A6E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 625CB5742678C33500530A6E /* LibraryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListViewModel.swift; sourceTree = ""; }; 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerViewModel.swift; sourceTree = ""; }; @@ -717,17 +686,10 @@ 62666E2D27E5021400EC0ECD /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/Security.framework; sourceTree = DEVELOPER_DIR; }; 62666E2F27E5021800EC0ECD /* VideoToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VideoToolbox.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/VideoToolbox.framework; sourceTree = DEVELOPER_DIR; }; 62666E3127E5021E00EC0ECD /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/UIKit.framework; sourceTree = DEVELOPER_DIR; }; - 62666E3327E5027F00EC0ECD /* Defaults+Workaround.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Defaults+Workaround.swift"; sourceTree = ""; }; 62666E3A27E503E400EC0ECD /* GoogleCastSDK.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GoogleCastSDK.xcframework; path = Frameworks/GoogleCastSDK.xcframework; sourceTree = ""; }; 6267B3D526710B8900A7371D /* CollectionExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtensions.swift; sourceTree = ""; }; - 6267B3D92671138200A7371D /* ImageExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageExtensions.swift; sourceTree = ""; }; - 628B95202670CABD0091AF3B /* Swiftfin Widget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Swiftfin Widget.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 628B95212670CABD0091AF3B /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 628B95232670CABD0091AF3B /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; - 628B95262670CABD0091AF3B /* NextUpWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpWidget.swift; sourceTree = ""; }; - 628B95282670CABE0091AF3B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 628B952A2670CABE0091AF3B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 628B95362670CB800091AF3B /* JellyfinWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinWidget.swift; sourceTree = ""; }; 62C29E9E26D1016600C1D2E7 /* iOSMainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSMainCoordinator.swift; sourceTree = ""; }; 62C29EA026D102A500C1D2E7 /* iOSMainTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSMainTabCoordinator.swift; sourceTree = ""; }; 62C29EA226D1030F00C1D2E7 /* ConnectToServerCoodinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerCoodinator.swift; sourceTree = ""; }; @@ -772,7 +734,6 @@ C4BE0762271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVLibrariesCoordinator.swift; sourceTree = ""; }; C4BE0765271FC109003F4AD1 /* TVLibrariesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVLibrariesViewModel.swift; sourceTree = ""; }; C4BE0768271FC164003F4AD1 /* TVLibrariesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVLibrariesView.swift; sourceTree = ""; }; - C4BE076D2720FEA8003F4AD1 /* PortraitItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemElement.swift; sourceTree = ""; }; C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsCoordinator.swift; sourceTree = ""; }; C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = ""; }; C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsViewModel.swift; sourceTree = ""; }; @@ -787,7 +748,6 @@ C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfoExtensions.swift; sourceTree = ""; }; - E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; E103A69F278A7E4500820EC7 /* UICinematicBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICinematicBackgroundView.swift; sourceTree = ""; }; E103A6A2278A7EC400820EC7 /* CinematicBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicBackgroundView.swift; sourceTree = ""; }; E103A6A4278A82E500820EC7 /* HomeCinematicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCinematicView.swift; sourceTree = ""; }; @@ -796,21 +756,27 @@ E1047E1F27E584AF00CB0D4A /* BlurHashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashView.swift; sourceTree = ""; }; E1047E2227E5880000CB0D4A /* InitialFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialFailureView.swift; sourceTree = ""; }; E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemViewModel.swift; sourceTree = ""; }; - E107BB952788104100354E07 /* CinematicCollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicCollectionItemView.swift; sourceTree = ""; }; - E10C0940278B8DAB009DBF93 /* PortraitItemSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemSize.swift; sourceTree = ""; }; - E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewDetailsView.swift; sourceTree = ""; }; - E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = ""; }; - E10D87DD278510E300BD264C /* PosterSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterSize.swift; sourceTree = ""; }; + E10D87DB2784EC5200BD264C /* SeriesEpisodesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesEpisodesView.swift; sourceTree = ""; }; E10D87E127852FD000BD264C /* EpisodesRowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowManager.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = ""; }; E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = ""; }; E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectBottomScrollView.swift; sourceTree = ""; }; + E118959C289312020042947B /* BaseItemPerson+Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemPerson+Poster.swift"; sourceTree = ""; }; + E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewOffsetModifier.swift; sourceTree = ""; }; + E11895AB289383EE0042947B /* NavBarOffsetModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarOffsetModifier.swift; sourceTree = ""; }; + E11895AE2893840F0042947B /* NavBarOffsetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarOffsetView.swift; sourceTree = ""; }; + E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundParallaxHeaderModifier.swift; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; + E11CEB8A28998552003E74C7 /* iOSViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSViewExtensions.swift; sourceTree = ""; }; + E11CEB8C28999B4A003E74C7 /* FontExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontExtensions.swift; sourceTree = ""; }; + E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; + E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeItemContentView.swift; sourceTree = ""; }; E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailCoordinator.swift; sourceTree = ""; }; E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStreamExtension.swift; sourceTree = ""; }; E1267D3D271A1F46003C492E /* PreferenceUIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingController.swift; sourceTree = ""; }; E126F740278A656C00A522BF /* ServerStreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerStreamType.swift; sourceTree = ""; }; E1384943278036C70024FB48 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = ""; }; + E1399473289B1EA900401ABC /* Defaults+Workaround.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Defaults+Workaround.swift"; sourceTree = ""; }; E13AD72D2798BC8D00FDCEE8 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; E13AD72F2798C60F00FDCEE8 /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; E13D02842788B634000FCB04 /* Swiftfin.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Swiftfin.entitlements; sourceTree = ""; }; @@ -828,25 +794,68 @@ E13DD3F82717E961009D4DAF /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; E13DD3FB2717EAE8009D4DAF /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListCoordinator.swift; sourceTree = ""; }; - E13F26AE278754E300DF4761 /* CinematicSeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicSeriesItemView.swift; sourceTree = ""; }; - E13F26B02787589300DF4761 /* CinematicSeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicSeasonItemView.swift; sourceTree = ""; }; - E14B4140279354770016CBE5 /* LocalizedLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedLookup.swift; sourceTree = ""; }; - E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitMainView.swift; sourceTree = ""; }; - E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeMainView.swift; sourceTree = ""; }; + E1546776289AF46E00087E35 /* CollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = ""; }; + E1546779289AF48200087E35 /* CollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemContentView.swift; sourceTree = ""; }; + E168BD08289A4162001A6922 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + E168BD09289A4162001A6922 /* HomeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = ""; }; + E168BD0C289A4162001A6922 /* ContinueWatchingCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingCard.swift; sourceTree = ""; }; + E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; + E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = ""; }; + E168BD0F289A4162001A6922 /* HomeErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; - E176DE6C278E30D2001EFD8D /* EpisodeRowCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeRowCard.swift; sourceTree = ""; }; + E176DE6C278E30D2001EFD8D /* EpisodeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = ""; }; E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingItemsSettingsView.swift; sourceTree = ""; }; E178859A2780F1F40094FBCF /* tvOSSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSSlider.swift; sourceTree = ""; }; E178859D2780F53B0094FBCF /* SliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderView.swift; sourceTree = ""; }; E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVLCOverlay.swift; sourceTree = ""; }; E17885A3278105170094FBCF /* SFSymbolButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSymbolButton.swift; sourceTree = ""; }; - E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = ""; }; - E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = ""; }; - E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = ""; }; + E184C15F288C5C08000B25BA /* RequestBuilderExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestBuilderExtensions.swift; sourceTree = ""; }; + E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Poster.swift"; sourceTree = ""; }; + E18E01A3288746AF0022598C /* PortraitPosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PortraitPosterHStack.swift; sourceTree = ""; }; + E18E01A4288746AF0022598C /* RefreshableScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; + E18E01A5288746AF0022598C /* PillHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PillHStack.swift; sourceTree = ""; }; + E18E01A7288746AF0022598C /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = ""; }; + E18E01B6288747230022598C /* iPadOSEpisodeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iPadOSEpisodeContentView.swift; sourceTree = ""; }; + E18E01B7288747230022598C /* iPadOSEpisodeItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iPadOSEpisodeItemView.swift; sourceTree = ""; }; + E18E01B9288747230022598C /* iPadOSCinematicScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iPadOSCinematicScrollView.swift; sourceTree = ""; }; + E18E01BB288747230022598C /* iPadOSSeriesItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iPadOSSeriesItemContentView.swift; sourceTree = ""; }; + E18E01BC288747230022598C /* iPadOSSeriesItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iPadOSSeriesItemView.swift; sourceTree = ""; }; + E18E01BE288747230022598C /* iPadOSMovieItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iPadOSMovieItemView.swift; sourceTree = ""; }; + E18E01BF288747230022598C /* iPadOSMovieItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iPadOSMovieItemContentView.swift; sourceTree = ""; }; + E18E01C2288747230022598C /* EpisodeItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeItemContentView.swift; sourceTree = ""; }; + E18E01C3288747230022598C /* EpisodeItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeItemView.swift; sourceTree = ""; }; + E18E01C5288747230022598C /* CompactPortraitScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompactPortraitScrollView.swift; sourceTree = ""; }; + E18E01C6288747230022598C /* CompactLogoScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompactLogoScrollView.swift; sourceTree = ""; }; + E18E01C7288747230022598C /* CinematicScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CinematicScrollView.swift; sourceTree = ""; }; + E18E01C9288747230022598C /* CollectionItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = ""; }; + E18E01CA288747230022598C /* CollectionItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionItemContentView.swift; sourceTree = ""; }; + E18E01CC288747230022598C /* SeriesItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemContentView.swift; sourceTree = ""; }; + E18E01CD288747230022598C /* SeriesItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = ""; }; + E18E01CF288747230022598C /* MovieItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = ""; }; + E18E01D0288747230022598C /* MovieItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieItemContentView.swift; sourceTree = ""; }; + E18E01D5288747230022598C /* AboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + E18E01D6288747230022598C /* ListDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListDetailsView.swift; sourceTree = ""; }; + E18E01D7288747230022598C /* AttributeHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeHStack.swift; sourceTree = ""; }; + E18E01D8288747230022598C /* PlayButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = ""; }; + E18E01D9288747230022598C /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = ""; }; + E18E01F3288747580022598C /* AboutAppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = ""; }; + E18E01F5288747580022598C /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + E18E01F6288747580022598C /* HomeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = ""; }; + E18E01F8288747580022598C /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = ""; }; + E18E01F9288747580022598C /* HomeErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; + E18E01FF288749200022598C /* Divider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Divider.swift; sourceTree = ""; }; + E18E0200288749200022598C /* AppIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = ""; }; + E18E0201288749200022598C /* AttributeFillView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeFillView.swift; sourceTree = ""; }; + E18E0202288749200022598C /* AttributeOutlineView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeOutlineView.swift; sourceTree = ""; }; + E18E0203288749200022598C /* BlurView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = ""; }; + E18E0239288749540022598C /* UIScrollViewExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIScrollViewExtensions.swift; sourceTree = ""; }; E19169CD272514760085832A /* HTTPScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPScheme.swift; sourceTree = ""; }; - E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitImageStackable.swift; sourceTree = ""; }; + E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Images.swift"; sourceTree = ""; }; + E1937A3D288F0D3D00CB80AA /* UIScreenExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScreenExtensions.swift; sourceTree = ""; }; + E1937A60288F32DB00CB80AA /* Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poster.swift; sourceTree = ""; }; + E1937A63288F683300CB80AA /* ContinueWatchingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingCard.swift; sourceTree = ""; }; E193D4DA27193CCA00900D82 /* PillStackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillStackable.swift; sourceTree = ""; }; E193D5422719407E00900D82 /* tvOSMainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSMainCoordinator.swift; sourceTree = ""; }; E193D546271941C500900D82 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; @@ -855,24 +864,30 @@ E193D54C2719426600900D82 /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; E193D54F2719430400900D82 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E193D552271943D500900D82 /* tvOSMainTabCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSMainTabCoordinator.swift; sourceTree = ""; }; - E1A2C150279A7008005EC829 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + E19E54F828972B97003CE330 /* ColourSpace.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColourSpace.swift; sourceTree = ""; }; + E19E54F928972B97003CE330 /* BlurHash.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHash.swift; sourceTree = ""; }; + E19E54FA28972B97003CE330 /* StringCoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringCoding.swift; sourceTree = ""; }; + E19E54FB28972B97003CE330 /* FromString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FromString.swift; sourceTree = ""; }; + E19E54FC28972B97003CE330 /* EscapeSequences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EscapeSequences.swift; sourceTree = ""; }; + E19E54FD28972B97003CE330 /* ColourProbes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ColourProbes.swift; sourceTree = ""; }; + E19E54FE28972B97003CE330 /* ToString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToString.swift; sourceTree = ""; }; + E19E54FF28972B97003CE330 /* ToUIImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToUIImage.swift; sourceTree = ""; }; + E19E550028972B97003CE330 /* FromUIImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FromUIImage.swift; sourceTree = ""; }; + E19E550228972B97003CE330 /* Generation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Generation.swift; sourceTree = ""; }; + E19E550328972B97003CE330 /* TupleMaths.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TupleMaths.swift; sourceTree = ""; }; + E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomEdgeGradientModifier.swift; sourceTree = ""; }; + E1A16C9C2889AF1E00EA4679 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + E1A16CA0288A7CFD00EA4679 /* AboutViewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewCard.swift; sourceTree = ""; }; E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtensions.swift; sourceTree = ""; }; E1A2C157279A7D76005EC829 /* BundleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtensions.swift; sourceTree = ""; }; - E1A2C15B279A7D9F005EC829 /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = ""; }; - E1A2C15F279A7DCA005EC829 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; - E1AA331C2782541500F6439C /* PrimaryButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButtonView.swift; sourceTree = ""; }; + E1A2C15F279A7DCA005EC829 /* AboutAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = ""; }; + E1AA331C2782541500F6439C /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; E1AA331E2782639D00F6439C /* OverlayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayType.swift; sourceTree = ""; }; - E1AA33212782648000F6439C /* OverlaySliderColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySliderColor.swift; sourceTree = ""; }; E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = ""; }; - E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitHStackView.swift; sourceTree = ""; }; - E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillHStackView.swift; sourceTree = ""; }; E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = ""; }; - E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitHeaderOverlayView.swift; sourceTree = ""; }; E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GoogleCast.xcframework; path = Carthage/Build/GoogleCast.xcframework; sourceTree = ""; }; E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingCard.swift; sourceTree = ""; }; - E1B59FD82786AE4600A5287E /* NextUpCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpCard.swift; sourceTree = ""; }; E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingItemsSettingsView.swift; sourceTree = ""; }; - E1BDE35A278EA3A3004E4022 /* EpisodesRowCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowCard.swift; sourceTree = ""; }; E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = ""; }; E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = ""; }; @@ -883,6 +898,23 @@ E1C812C8277AE40900918266 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; E1C812C9277AE40900918266 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVideoPlayerCoordinator.swift; sourceTree = ""; }; + E1C925F328875037002A7A66 /* ItemViewType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemViewType.swift; sourceTree = ""; }; + E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PanDirectionGestureRecognizer.swift; sourceTree = ""; }; + E1C925F828875647002A7A66 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = ""; }; + E1C925FB2887565C002A7A66 /* MovieItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieItemView.swift; sourceTree = ""; }; + E1C925FC2887565C002A7A66 /* MovieItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MovieItemContentView.swift; sourceTree = ""; }; + E1C925FE2887565C002A7A66 /* CinematicScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CinematicScrollView.swift; sourceTree = ""; }; + E1C926012887565C002A7A66 /* AttributeHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeHStack.swift; sourceTree = ""; }; + E1C926022887565C002A7A66 /* PlayButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = ""; }; + E1C926032887565C002A7A66 /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = ""; }; + E1C926052887565C002A7A66 /* SeriesItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemContentView.swift; sourceTree = ""; }; + E1C926072887565C002A7A66 /* SeriesEpisodesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesEpisodesView.swift; sourceTree = ""; }; + E1C926082887565C002A7A66 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = ""; }; + E1C926092887565C002A7A66 /* EpisodeCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = ""; }; + E1C9260A2887565C002A7A66 /* SeriesItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = ""; }; + E1C92617288756BD002A7A66 /* PortraitButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PortraitButton.swift; sourceTree = ""; }; + E1C92618288756BD002A7A66 /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = ""; }; + E1C92619288756BD002A7A66 /* PortraitPosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PortraitPosterHStack.swift; sourceTree = ""; }; E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; @@ -894,14 +926,7 @@ E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; E1E00A34278628A40022235B /* DoubleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleExtensions.swift; sourceTree = ""; }; E1E48CC8271E6D410021A2F9 /* RefreshHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshHelper.swift; sourceTree = ""; }; - E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicEpisodeItemView.swift; sourceTree = ""; }; - E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = ""; }; - E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemViewTopRow.swift; sourceTree = ""; }; - E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicMovieItemView.swift; sourceTree = ""; }; - E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemViewTopRowButton.swift; sourceTree = ""; }; - E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitItemsRowView.swift; sourceTree = ""; }; E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemDetailsView.swift; sourceTree = ""; }; - E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemAboutView.swift; sourceTree = ""; }; E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = ""; }; E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = ""; }; E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = ""; }; @@ -912,6 +937,8 @@ E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewView.swift; sourceTree = ""; }; E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = ""; }; E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallMenuOverlay.swift; sourceTree = ""; }; + E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemView.swift; sourceTree = ""; }; + E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemContentView.swift; sourceTree = ""; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -937,7 +964,6 @@ 637FCAF5287B5B2600C0A353 /* UDPBroadcast.xcframework in Frameworks */, 535870912669D7A800D05A09 /* Introspect in Frameworks */, 62666E1B27E501D400EC0ECD /* CoreGraphics.framework in Frameworks */, - 536D3D84267BEA550004248C /* ParallaxView in Frameworks */, 62666E2C27E5021000EC0ECD /* QuartzCore.framework in Frameworks */, 62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */, 62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */, @@ -997,21 +1023,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 628B951D2670CABD0091AF3B /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - E1347DB4279E3C9E00BC6161 /* Puppy in Frameworks */, - E1D7E5A827892566009D0EF7 /* Nuke in Frameworks */, - 628B95242670CABD0091AF3B /* SwiftUI.framework in Frameworks */, - 531ABF6C2671F5CC00C0FE20 /* WidgetKit.framework in Frameworks */, - E13DD3DD27175CE3009D4DAF /* Defaults in Frameworks */, - 536D3D7D267BD5F90004248C /* ActivityIndicator in Frameworks */, - E13DD3CF27164E1F009D4DAF /* CoreStore in Frameworks */, - E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1054,7 +1065,6 @@ 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */, C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */, C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */, - 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */, C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */, @@ -1124,6 +1134,7 @@ 535870662669D21700D05A09 /* Assets.xcassets */, 536D3D77267BB9650004248C /* Components */, 535870702669D21700D05A09 /* Info.plist */, + E1A16C9328875F2F00EA4679 /* Objects */, 535870682669D21700D05A09 /* Preview Content */, E12186E02718F23B0010884C /* Views */, ); @@ -1141,8 +1152,7 @@ 535870752669D60C00D05A09 /* Shared */ = { isa = PBXGroup; children = ( - 625534272821908D0087FE20 /* UIKit */, - 6286F09F271C0AA500C40ED5 /* Generated */, + E19E54F728972B97003CE330 /* BlurHashKit */, 62C29E9D26D0FE5900C1D2E7 /* Coordinators */, E1FCD08E26C466F3007C8DCF /* Errors */, 621338912660106C00A81A2A /* Extensions */, @@ -1150,6 +1160,7 @@ AE8C3157265D6F5E008AA076 /* Resources */, 091B5A852683142E00D78B61 /* ServerDiscovery */, 62EC352A26766657000E9F2D /* Singleton */, + 6286F09F271C0AA500C40ED5 /* Strings */, E13DD3C0271648EC009D4DAF /* SwiftfinStore */, 532175392671BCED005491E6 /* ViewModels */, E1AD105326D96F5A003E4A08 /* Views */, @@ -1162,15 +1173,15 @@ children = ( E1D4BF802719D22800A11E64 /* AppAppearance.swift */, E1D4BF862719D27100A11E64 /* Bitrates.swift */, + E1C925F328875037002A7A66 /* ItemViewType.swift */, 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */, E19169CD272514760085832A /* HTTPScheme.swift */, - E1AA33212782648000F6439C /* OverlaySliderColor.swift */, E1AA331E2782639D00F6439C /* OverlayType.swift */, + E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */, E193D4DA27193CCA00900D82 /* PillStackable.swift */, E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, - E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */, - E10D87DD278510E300BD264C /* PosterSize.swift */, + E1937A60288F32DB00CB80AA /* Poster.swift */, 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */, E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */, 535870AC2669D8DD00D05A09 /* Typings.swift */, @@ -1182,16 +1193,13 @@ 536D3D77267BB9650004248C /* Components */ = { isa = PBXGroup; children = ( - E1BDE35C278EA3A7004E4022 /* EpisodesRowView */, + E1C92618288756BD002A7A66 /* DotHStack.swift */, E103A6A1278A7EB500820EC7 /* HomeCinematicView */, E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */, - E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */, - 53272531268BF09D0035FBF1 /* MediaViewActionButton.swift */, - 53116A18268B947A003024C9 /* PlainLinkButton.swift */, + E1C92617288756BD002A7A66 /* PortraitButton.swift */, + E1C92619288756BD002A7A66 /* PortraitPosterHStack.swift */, 536D3D80267BDFC60004248C /* PortraitItemElement.swift */, - E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */, - 536D3D87267C17350004248C /* PublicUserButton.swift */, E17885A3278105170094FBCF /* SFSymbolButton.swift */, ); path = Components; @@ -1206,7 +1214,7 @@ 535870612669D21600D05A09 /* Swiftfin tvOS */, 5377CBF2263B596A003A4E83 /* Products */, 535870752669D60C00D05A09 /* Shared */, - 628B95252670CABD0091AF3B /* WidgetExtension */, + E168BD06289A414B001A6922 /* Recovered References */, ); sourceTree = ""; }; @@ -1215,7 +1223,6 @@ children = ( 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */, 535870602669D21600D05A09 /* Swiftfin tvOS.app */, - 628B95202670CABD0091AF3B /* Swiftfin Widget.appex */, ); name = Products; sourceTree = ""; @@ -1230,6 +1237,7 @@ 53F866422687A45400DCD1D7 /* Components */, 5377CC02263B596B003A4E83 /* Info.plist */, E13D02842788B634000FCB04 /* Swiftfin.entitlements */, + E11CEB85289984F5003E74C7 /* Extensions */, 5377CBFA263B596B003A4E83 /* Preview Content */, E13DD3D027165886009D4DAF /* Views */, ); @@ -1422,15 +1430,13 @@ 53F866422687A45400DCD1D7 /* Components */ = { isa = PBXGroup; children = ( - E1A2C15B279A7D9F005EC829 /* AppIcon.swift */, E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */, - E176DE6E278E3522001EFD8D /* EpisodesRowView */, - E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */, - E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */, - 53F866432687A45F00DCD1D7 /* PortraitItemButton.swift */, - C4BE076D2720FEA8003F4AD1 /* PortraitItemElement.swift */, - E1AA331C2782541500F6439C /* PrimaryButtonView.swift */, - E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */, + E18E01A7288746AF0022598C /* DotHStack.swift */, + E18E01A5288746AF0022598C /* PillHStack.swift */, + E18E01A3288746AF0022598C /* PortraitPosterHStack.swift */, + 53F866432687A45F00DCD1D7 /* PortraitPosterButton.swift */, + E1AA331C2782541500F6439C /* PrimaryButton.swift */, + E18E01A4288746AF0022598C /* RefreshableScrollView.swift */, ); path = Components; sourceTree = ""; @@ -1447,52 +1453,33 @@ 621338912660106C00A81A2A /* Extensions */ = { isa = PBXGroup; children = ( - 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */, + E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */, E1A2C157279A7D76005EC829 /* BundleExtensions.swift */, E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */, 6267B3D526710B8900A7371D /* CollectionExtensions.swift */, E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */, + E1399473289B1EA900401ABC /* Defaults+Workaround.swift */, E1E00A34278628A40022235B /* DoubleExtensions.swift */, - 6267B3D92671138200A7371D /* ImageExtensions.swift */, - E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */, + E11CEB8C28999B4A003E74C7 /* FontExtensions.swift */, 621338922660107500A81A2A /* StringExtensions.swift */, E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */, E13DD3C727164B1E009D4DAF /* UIDeviceExtensions.swift */, + E18E0239288749540022598C /* UIScrollViewExtensions.swift */, + E1937A3D288F0D3D00CB80AA /* UIScreenExtensions.swift */, E1C812C4277A90B200918266 /* URLComponentsExtensions.swift */, 62E1DCC2273CE19800C9AE76 /* URLExtensions.swift */, - 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */, + E11895A22893409D0042947B /* ViewExtensions */, 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */, - 62666E3327E5027F00EC0ECD /* Defaults+Workaround.swift */, ); path = Extensions; sourceTree = ""; }; - 625534272821908D0087FE20 /* UIKit */ = { - isa = PBXGroup; - children = ( - 62553428282190A00087FE20 /* PanDirectionGestureRecognizer.swift */, - ); - path = UIKit; - sourceTree = ""; - }; - 6286F09F271C0AA500C40ED5 /* Generated */ = { + 6286F09F271C0AA500C40ED5 /* Strings */ = { isa = PBXGroup; children = ( 6264E88B273850380081A12A /* Strings.swift */, - E14B4140279354770016CBE5 /* LocalizedLookup.swift */, ); - path = Generated; - sourceTree = ""; - }; - 628B95252670CABD0091AF3B /* WidgetExtension */ = { - isa = PBXGroup; - children = ( - 628B95362670CB800091AF3B /* JellyfinWidget.swift */, - 628B95262670CABD0091AF3B /* NextUpWidget.swift */, - 628B95282670CABE0091AF3B /* Assets.xcassets */, - 628B952A2670CABE0091AF3B /* Info.plist */, - ); - path = WidgetExtension; + path = Strings; sourceTree = ""; }; 62C29E9D26D0FE5900C1D2E7 /* Coordinators */ = { @@ -1527,7 +1514,6 @@ 62EC352A26766657000E9F2D /* Singleton */ = { isa = PBXGroup; children = ( - 536D3D73267BA8170004248C /* BackgroundManager.swift */, 53649AB0269CFB1900A2D8B7 /* LogManager.swift */, 62EC352E267666A5000E9F2D /* SessionManager.swift */, E13DD3EE27178F87009D4DAF /* SwiftfinNotificationCenter.swift */, @@ -1586,6 +1572,52 @@ path = ItemViewModel; sourceTree = ""; }; + E11895A22893409D0042947B /* ViewExtensions */ = { + isa = PBXGroup; + children = ( + E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */, + E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */, + E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */, + 6220D0AC26D5EABB00B8E046 /* ViewExtensions.swift */, + ); + path = ViewExtensions; + sourceTree = ""; + }; + E11895B12893842D0042947B /* NavBarOffsetModifier */ = { + isa = PBXGroup; + children = ( + E11895AB289383EE0042947B /* NavBarOffsetModifier.swift */, + E11895AE2893840F0042947B /* NavBarOffsetView.swift */, + ); + path = NavBarOffsetModifier; + sourceTree = ""; + }; + E11CEB85289984F5003E74C7 /* Extensions */ = { + isa = PBXGroup; + children = ( + E11CEB8828998522003E74C7 /* iOSViewExtensions */, + ); + path = Extensions; + sourceTree = ""; + }; + E11CEB8828998522003E74C7 /* iOSViewExtensions */ = { + isa = PBXGroup; + children = ( + E11895B12893842D0042947B /* NavBarOffsetModifier */, + E11CEB8A28998552003E74C7 /* iOSViewExtensions.swift */, + ); + path = iOSViewExtensions; + sourceTree = ""; + }; + E11CEB9228999D8D003E74C7 /* EpisodeItemView */ = { + isa = PBXGroup; + children = ( + E11CEB9328999D9E003E74C7 /* EpisodeItemContentView.swift */, + E11CEB8F28999D84003E74C7 /* EpisodeItemView.swift */, + ); + path = EpisodeItemView; + sourceTree = ""; + }; E12186DF2718F2030010884C /* App */ = { isa = PBXGroup; children = ( @@ -1597,14 +1629,14 @@ E12186E02718F23B0010884C /* Views */ = { isa = PBXGroup; children = ( - E1A2C15F279A7DCA005EC829 /* AboutView.swift */, + E1A2C15F279A7DCA005EC829 /* AboutAppView.swift */, E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */, 53ABFDEA2679753200886593 /* ConnectToServerView.swift */, E1B59FD62786AE2C00A5287E /* ContinueWatchingView */, 531690E6267ABD79005D8AB9 /* HomeView.swift */, E193D54E271942C000900D82 /* ItemView */, - 536D3D7E267BDF100004248C /* LatestMediaView.swift */, E193D54C2719426600900D82 /* LibraryFilterView.swift */, + E1C925F828875647002A7A66 /* LatestInLibraryView.swift */, C4E508172703E8190045C9AB /* LibraryListView.swift */, C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */, 53A83C32268A309300DF3D92 /* LibraryView.swift */, @@ -1613,7 +1645,6 @@ C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */, C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */, C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */, - E1B59FD72786AE3E00A5287E /* NextUpView */, E193D54F2719430400900D82 /* ServerDetailView.swift */, E193D54A271941D300900D82 /* ServerListView.swift */, E1E5D54D2783E66600692DFE /* SettingsView */, @@ -1656,63 +1687,99 @@ E13DD3D027165886009D4DAF /* Views */ = { isa = PBXGroup; children = ( - E1A2C150279A7008005EC829 /* AboutView.swift */, + E18E01F3288747580022598C /* AboutAppView.swift */, E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */, 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, - 5389276D263C25100035E14B /* ContinueWatchingView.swift */, 62C83B07288C6A630004ED0C /* FontPicker.swift */, - 625CB56E2678C23300530A6E /* HomeView.swift */, + E168BD07289A4162001A6922 /* HomeView */, E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */, E14F7D0A26DB3714007C3AE6 /* ItemView */, - 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */, 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */, 6213388F265F83A900A81A2A /* LibraryListView.swift */, 53EE24E5265060780068F029 /* LibrarySearchView.swift */, - C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */, + 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */, C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */, - 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, + C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */, C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */, C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */, + 631759CE2879DB6A00A621AD /* PublicUserSignInCellView.swift */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, E13DD3E427177D15009D4DAF /* ServerListView.swift */, E1E5D54A2783E26100692DFE /* SettingsView */, E13DD3FB2717EAE8009D4DAF /* UserListView.swift */, E13DD3F4271793BB009D4DAF /* UserSignInView.swift */, - 631759CE2879DB6A00A621AD /* PublicUserSignInCellView.swift */, E193D5452719418B00900D82 /* VideoPlayer */, ); path = Views; sourceTree = ""; }; - E13F26AD27874ECC00DF4761 /* CompactItemView */ = { - isa = PBXGroup; - children = ( - 53272538268C20100035FBF1 /* EpisodeItemView.swift */, - 53CD2A41268A4B38002ABD4E /* MovieItemView.swift */, - 53272536268C1DBB0035FBF1 /* SeasonItemView.swift */, - 53116A16268B919A003024C9 /* SeriesItemView.swift */, - ); - path = CompactItemView; - sourceTree = ""; - }; E14F7D0A26DB3714007C3AE6 /* ItemView */ = { isa = PBXGroup; children = ( 535BAE9E2649E569005FA86D /* ItemView.swift */, - E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */, - E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */, - E18845FB26DEACC400B0C5B7 /* Landscape */, - E18845FA26DEACBE00B0C5B7 /* Portrait */, + E18E01D4288747230022598C /* Components */, + E18E01C0288747230022598C /* iOS */, + E18E01B4288747230022598C /* iPadOS */, ); path = ItemView; sourceTree = ""; }; + E1546778289AF47100087E35 /* CollectionItemView */ = { + isa = PBXGroup; + children = ( + E1546776289AF46E00087E35 /* CollectionItemView.swift */, + E1546779289AF48200087E35 /* CollectionItemContentView.swift */, + ); + path = CollectionItemView; + sourceTree = ""; + }; + E168BD06289A414B001A6922 /* Recovered References */ = { + isa = PBXGroup; + children = ( + E18E01F9288747580022598C /* HomeErrorView.swift */, + E18E01F8288747580022598C /* LatestInLibraryView.swift */, + E18E01F6288747580022598C /* HomeContentView.swift */, + E18E01F5288747580022598C /* HomeView.swift */, + E1937A63288F683300CB80AA /* ContinueWatchingCard.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; + E168BD07289A4162001A6922 /* HomeView */ = { + isa = PBXGroup; + children = ( + E168BD0A289A4162001A6922 /* Components */, + E168BD09289A4162001A6922 /* HomeContentView.swift */, + E168BD0F289A4162001A6922 /* HomeErrorView.swift */, + E168BD08289A4162001A6922 /* HomeView.swift */, + ); + path = HomeView; + sourceTree = ""; + }; + E168BD0A289A4162001A6922 /* Components */ = { + isa = PBXGroup; + children = ( + E168BD0B289A4162001A6922 /* ContinueWatchingView */, + E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */, + ); + path = Components; + sourceTree = ""; + }; + E168BD0B289A4162001A6922 /* ContinueWatchingView */ = { + isa = PBXGroup; + children = ( + E168BD0C289A4162001A6922 /* ContinueWatchingCard.swift */, + E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */, + ); + path = ContinueWatchingView; + sourceTree = ""; + }; E176DE6E278E3522001EFD8D /* EpisodesRowView */ = { isa = PBXGroup; children = ( - E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */, - E176DE6C278E30D2001EFD8D /* EpisodeRowCard.swift */, + E10D87DB2784EC5200BD264C /* SeriesEpisodesView.swift */, + E176DE6C278E30D2001EFD8D /* EpisodeCard.swift */, ); path = EpisodesRowView; sourceTree = ""; @@ -1737,22 +1804,122 @@ path = Overlays; sourceTree = ""; }; - E18845FA26DEACBE00B0C5B7 /* Portrait */ = { + E18E01B4288747230022598C /* iPadOS */ = { isa = PBXGroup; children = ( - E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */, - E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */, + E1FA891C289A302600176FEB /* CollectionItemView */, + E18E01B5288747230022598C /* EpisodeItemView */, + E18E01BD288747230022598C /* MovieItemView */, + E18E01B8288747230022598C /* ScrollViews */, + E18E01BA288747230022598C /* SeriesItemView */, ); - path = Portrait; + path = iPadOS; sourceTree = ""; }; - E18845FB26DEACC400B0C5B7 /* Landscape */ = { + E18E01B5288747230022598C /* EpisodeItemView */ = { isa = PBXGroup; children = ( - E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */, - E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */, + E18E01B6288747230022598C /* iPadOSEpisodeContentView.swift */, + E18E01B7288747230022598C /* iPadOSEpisodeItemView.swift */, ); - path = Landscape; + path = EpisodeItemView; + sourceTree = ""; + }; + E18E01B8288747230022598C /* ScrollViews */ = { + isa = PBXGroup; + children = ( + E18E01B9288747230022598C /* iPadOSCinematicScrollView.swift */, + ); + path = ScrollViews; + sourceTree = ""; + }; + E18E01BA288747230022598C /* SeriesItemView */ = { + isa = PBXGroup; + children = ( + E18E01BB288747230022598C /* iPadOSSeriesItemContentView.swift */, + E18E01BC288747230022598C /* iPadOSSeriesItemView.swift */, + ); + path = SeriesItemView; + sourceTree = ""; + }; + E18E01BD288747230022598C /* MovieItemView */ = { + isa = PBXGroup; + children = ( + E18E01BE288747230022598C /* iPadOSMovieItemView.swift */, + E18E01BF288747230022598C /* iPadOSMovieItemContentView.swift */, + ); + path = MovieItemView; + sourceTree = ""; + }; + E18E01C0288747230022598C /* iOS */ = { + isa = PBXGroup; + children = ( + E18E01C8288747230022598C /* CollectionItemView */, + E18E01C1288747230022598C /* EpisodeItemView */, + E18E01CE288747230022598C /* MovieItemView */, + E18E01C4288747230022598C /* ScrollViews */, + E18E01CB288747230022598C /* SeriesItemView */, + ); + path = iOS; + sourceTree = ""; + }; + E18E01C1288747230022598C /* EpisodeItemView */ = { + isa = PBXGroup; + children = ( + E18E01C2288747230022598C /* EpisodeItemContentView.swift */, + E18E01C3288747230022598C /* EpisodeItemView.swift */, + ); + path = EpisodeItemView; + sourceTree = ""; + }; + E18E01C4288747230022598C /* ScrollViews */ = { + isa = PBXGroup; + children = ( + E18E01C5288747230022598C /* CompactPortraitScrollView.swift */, + E18E01C6288747230022598C /* CompactLogoScrollView.swift */, + E18E01C7288747230022598C /* CinematicScrollView.swift */, + ); + path = ScrollViews; + sourceTree = ""; + }; + E18E01C8288747230022598C /* CollectionItemView */ = { + isa = PBXGroup; + children = ( + E18E01C9288747230022598C /* CollectionItemView.swift */, + E18E01CA288747230022598C /* CollectionItemContentView.swift */, + ); + path = CollectionItemView; + sourceTree = ""; + }; + E18E01CB288747230022598C /* SeriesItemView */ = { + isa = PBXGroup; + children = ( + E18E01CC288747230022598C /* SeriesItemContentView.swift */, + E18E01CD288747230022598C /* SeriesItemView.swift */, + ); + path = SeriesItemView; + sourceTree = ""; + }; + E18E01CE288747230022598C /* MovieItemView */ = { + isa = PBXGroup; + children = ( + E18E01D0288747230022598C /* MovieItemContentView.swift */, + E18E01CF288747230022598C /* MovieItemView.swift */, + ); + path = MovieItemView; + sourceTree = ""; + }; + E18E01D4288747230022598C /* Components */ = { + isa = PBXGroup; + children = ( + E176DE6E278E3522001EFD8D /* EpisodesRowView */, + E18E01D5288747230022598C /* AboutView.swift */, + E18E01D6288747230022598C /* ListDetailsView.swift */, + E18E01D7288747230022598C /* AttributeHStack.swift */, + E18E01D8288747230022598C /* PlayButton.swift */, + E18E01D9288747230022598C /* ActionButtonHStack.swift */, + ); + path = Components; sourceTree = ""; }; E193D5412719404B00900D82 /* MainCoordinator */ = { @@ -1784,24 +1951,65 @@ E193D54E271942C000900D82 /* ItemView */ = { isa = PBXGroup; children = ( - E1E5D53C2783A85F00692DFE /* CinematicItemView */, - E13F26AD27874ECC00DF4761 /* CompactItemView */, 53CD2A3F268A49C2002ABD4E /* ItemView.swift */, + E1546778289AF47100087E35 /* CollectionItemView */, + E1C925FF2887565C002A7A66 /* Components */, + E1C925FA2887565C002A7A66 /* MovieItemView */, + E1C925FD2887565C002A7A66 /* ScrollViews */, + E1C926042887565C002A7A66 /* SeriesItemView */, ); path = ItemView; sourceTree = ""; }; + E19E54F728972B97003CE330 /* BlurHashKit */ = { + isa = PBXGroup; + children = ( + E19E54F828972B97003CE330 /* ColourSpace.swift */, + E19E54F928972B97003CE330 /* BlurHash.swift */, + E19E54FA28972B97003CE330 /* StringCoding.swift */, + E19E54FB28972B97003CE330 /* FromString.swift */, + E19E54FC28972B97003CE330 /* EscapeSequences.swift */, + E19E54FD28972B97003CE330 /* ColourProbes.swift */, + E19E54FE28972B97003CE330 /* ToString.swift */, + E19E54FF28972B97003CE330 /* ToUIImage.swift */, + E19E550028972B97003CE330 /* FromUIImage.swift */, + E19E550228972B97003CE330 /* Generation.swift */, + E19E550328972B97003CE330 /* TupleMaths.swift */, + ); + path = BlurHashKit; + sourceTree = ""; + }; + E1A16C9328875F2F00EA4679 /* Objects */ = { + isa = PBXGroup; + children = ( + E1C926082887565C002A7A66 /* FocusGuide.swift */, + ); + path = Objects; + sourceTree = ""; + }; + E1A16CA2288A7D0000EA4679 /* AboutView */ = { + isa = PBXGroup; + children = ( + E1A16C9C2889AF1E00EA4679 /* AboutView.swift */, + E1A16CA0288A7CFD00EA4679 /* AboutViewCard.swift */, + ); + path = AboutView; + sourceTree = ""; + }; E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */ = { isa = PBXGroup; children = ( - E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */, + E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */, + E18845F426DD631E00B0C5B7 /* BaseItemDto+Poster.swift */, E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */, E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */, + E118959C289312020042947B /* BaseItemPerson+Poster.swift */, 5364F454266CA0DC0026ECBA /* BaseItemPersonExtensions.swift */, E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */, E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */, E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */, E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */, + E184C15F288C5C08000B25BA /* RequestBuilderExtensions.swift */, ); path = JellyfinAPIExtensions; sourceTree = ""; @@ -1809,16 +2017,19 @@ E1AD105326D96F5A003E4A08 /* Views */ = { isa = PBXGroup; children = ( + E18E0200288749200022598C /* AppIcon.swift */, + E18E0201288749200022598C /* AttributeFillView.swift */, + E18E0202288749200022598C /* AttributeOutlineView.swift */, E1047E1F27E584AF00CB0D4A /* BlurHashView.swift */, + E18E0203288749200022598C /* BlurView.swift */, + E18E01FF288749200022598C /* Divider.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */, E1047E2227E5880000CB0D4A /* InitialFailureView.swift */, - 621338B22660A07800A81A2A /* LazyView.swift */, 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */, - 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */, 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */, - E10C0940278B8DAB009DBF93 /* PortraitItemSize.swift */, 624C21742685CF60007F1390 /* SearchablePickerView.swift */, 53DE4BD1267098F300739748 /* SearchBarView.swift */, + E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */, ); path = Views; sourceTree = ""; @@ -1832,24 +2043,6 @@ path = ContinueWatchingView; sourceTree = ""; }; - E1B59FD72786AE3E00A5287E /* NextUpView */ = { - isa = PBXGroup; - children = ( - 531690EE267ABF72005D8AB9 /* NextUpView.swift */, - E1B59FD82786AE4600A5287E /* NextUpCard.swift */, - ); - path = NextUpView; - sourceTree = ""; - }; - E1BDE35C278EA3A7004E4022 /* EpisodesRowView */ = { - isa = PBXGroup; - children = ( - E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */, - E1BDE35A278EA3A3004E4022 /* EpisodesRowCard.swift */, - ); - path = EpisodesRowView; - sourceTree = ""; - }; E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = { isa = PBXGroup; children = ( @@ -1861,6 +2054,54 @@ path = VideoPlayerCoordinator; sourceTree = ""; }; + E1C925FA2887565C002A7A66 /* MovieItemView */ = { + isa = PBXGroup; + children = ( + E1C925FC2887565C002A7A66 /* MovieItemContentView.swift */, + E1C925FB2887565C002A7A66 /* MovieItemView.swift */, + ); + path = MovieItemView; + sourceTree = ""; + }; + E1C925FD2887565C002A7A66 /* ScrollViews */ = { + isa = PBXGroup; + children = ( + E1C925FE2887565C002A7A66 /* CinematicScrollView.swift */, + ); + path = ScrollViews; + sourceTree = ""; + }; + E1C925FF2887565C002A7A66 /* Components */ = { + isa = PBXGroup; + children = ( + E11CEB9228999D8D003E74C7 /* EpisodeItemView */, + E1A16CA2288A7D0000EA4679 /* AboutView */, + E1C926032887565C002A7A66 /* ActionButtonHStack.swift */, + E1C926012887565C002A7A66 /* AttributeHStack.swift */, + E1C926022887565C002A7A66 /* PlayButton.swift */, + ); + path = Components; + sourceTree = ""; + }; + E1C926042887565C002A7A66 /* SeriesItemView */ = { + isa = PBXGroup; + children = ( + E1C926062887565C002A7A66 /* Components */, + E1C926052887565C002A7A66 /* SeriesItemContentView.swift */, + E1C9260A2887565C002A7A66 /* SeriesItemView.swift */, + ); + path = SeriesItemView; + sourceTree = ""; + }; + E1C926062887565C002A7A66 /* Components */ = { + isa = PBXGroup; + children = ( + E1C926072887565C002A7A66 /* SeriesEpisodesView.swift */, + E1C926092887565C002A7A66 /* EpisodeCard.swift */, + ); + path = Components; + sourceTree = ""; + }; E1DD1127271E7D15005BE12F /* Objects */ = { isa = PBXGroup; children = ( @@ -1869,21 +2110,6 @@ path = Objects; sourceTree = ""; }; - E1E5D53C2783A85F00692DFE /* CinematicItemView */ = { - isa = PBXGroup; - children = ( - E107BB952788104100354E07 /* CinematicCollectionItemView.swift */, - E1E5D5362783A52C00692DFE /* CinematicEpisodeItemView.swift */, - E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */, - E1E5D53A2783A80900692DFE /* CinematicItemViewTopRow.swift */, - E1E5D53F2783B0C000692DFE /* CinematicItemViewTopRowButton.swift */, - E1E5D53D2783B05200692DFE /* CinematicMovieItemView.swift */, - E13F26B02787589300DF4761 /* CinematicSeasonItemView.swift */, - E13F26AE278754E300DF4761 /* CinematicSeriesItemView.swift */, - ); - path = CinematicItemView; - sourceTree = ""; - }; E1E5D54A2783E26100692DFE /* SettingsView */ = { isa = PBXGroup; children = ( @@ -1909,6 +2135,15 @@ path = SettingsView; sourceTree = ""; }; + E1FA891C289A302600176FEB /* CollectionItemView */ = { + isa = PBXGroup; + children = ( + E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */, + E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */, + ); + path = CollectionItemView; + sourceTree = ""; + }; E1FCD08E26C466F3007C8DCF /* Errors */ = { isa = PBXGroup; children = ( @@ -1939,7 +2174,6 @@ packageProductDependencies = ( 535870902669D7A800D05A09 /* Introspect */, 53ABFDEC26799D7700886593 /* ActivityIndicator */, - 536D3D83267BEA550004248C /* ParallaxView */, 6220D0C826D63F3700B8E046 /* Stinsen */, E13DD3CC27164CA7009D4DAF /* CoreStore */, E12186DD2718F1C50010884C /* Defaults */, @@ -1970,7 +2204,6 @@ buildRules = ( ); dependencies = ( - 6264E889273848760081A12A /* PBXTargetDependency */, ); name = "Swiftfin iOS"; packageProductDependencies = ( @@ -1995,31 +2228,6 @@ productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; productType = "com.apple.product-type.application"; }; - 628B951F2670CABD0091AF3B /* Swiftfin Widget */ = { - isa = PBXNativeTarget; - buildConfigurationList = 628B952E2670CABE0091AF3B /* Build configuration list for PBXNativeTarget "Swiftfin Widget" */; - buildPhases = ( - 628B951C2670CABD0091AF3B /* Sources */, - 628B951D2670CABD0091AF3B /* Frameworks */, - 628B951E2670CABD0091AF3B /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "Swiftfin Widget"; - packageProductDependencies = ( - 536D3D7C267BD5F90004248C /* ActivityIndicator */, - E13DD3CE27164E1F009D4DAF /* CoreStore */, - E13DD3DC27175CE3009D4DAF /* Defaults */, - E10EAA46277BB670000269ED /* JellyfinAPI */, - E1D7E5A727892566009D0EF7 /* Nuke */, - E1347DB3279E3C9E00BC6161 /* Puppy */, - ); - productName = WidgetExtensionExtension; - productReference = 628B95202670CABD0091AF3B /* Swiftfin Widget.appex */; - productType = "com.apple.product-type.app-extension"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -2038,9 +2246,6 @@ 5377CBF0263B596A003A4E83 = { CreatedOnToolsVersion = 12.5; }; - 628B951F2670CABD0091AF3B = { - CreatedOnToolsVersion = 12.5; - }; }; }; buildConfigurationList = 5377CBEC263B596A003A4E83 /* Build configuration list for PBXProject "Swiftfin" */; @@ -2070,7 +2275,6 @@ packageReferences = ( 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */, - 536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */, 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */, E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */, E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */, @@ -2091,7 +2295,6 @@ projectRoot = ""; targets = ( 5377CBF0263B596A003A4E83 /* Swiftfin iOS */, - 628B951F2670CABD0091AF3B /* Swiftfin Widget */, 5358705F2669D21600D05A09 /* Swiftfin tvOS */, ); }; @@ -2151,30 +2354,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 628B951E2670CABD0091AF3B /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 53913C1526D323FE00EB3286 /* Localizable.strings in Resources */, - 53913BF126D323FE00EB3286 /* Localizable.strings in Resources */, - 53913C0626D323FE00EB3286 /* Localizable.strings in Resources */, - 53913BF426D323FE00EB3286 /* Localizable.strings in Resources */, - 53913C0C26D323FE00EB3286 /* Localizable.strings in Resources */, - 53913C0326D323FE00EB3286 /* Localizable.strings in Resources */, - 628B95292670CABE0091AF3B /* Assets.xcassets in Resources */, - 53913BF726D323FE00EB3286 /* Localizable.strings in Resources */, - 53913BFD26D323FE00EB3286 /* Localizable.strings in Resources */, - 53913C0026D323FE00EB3286 /* Localizable.strings in Resources */, - 534D4FF826A7D7CC000A7A48 /* Localizable.strings in Resources */, - 53913C0F26D323FE00EB3286 /* Localizable.strings in Resources */, - 53913C0926D323FE00EB3286 /* Localizable.strings in Resources */, - 53913BFA26D323FE00EB3286 /* Localizable.strings in Resources */, - 53913C1226D323FE00EB3286 /* Localizable.strings in Resources */, - 534D4FF526A7D7CC000A7A48 /* Localizable.strings in Resources */, - 534D4FF226A7D7CC000A7A48 /* Localizable.strings in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2223,185 +2402,200 @@ files = ( E193D53627193F8500900D82 /* LibraryCoordinator.swift in Sources */, C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */, - E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, - E1E5D53E2783B05200692DFE /* CinematicMovieItemView.swift in Sources */, + E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E193D4DC27193CCA00900D82 /* PillStackable.swift in Sources */, E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */, - 6267B3DC2671139500A7371D /* ImageExtensions.swift in Sources */, E103A6A9278AB6FF00820EC7 /* CinematicNextUpCardView.swift in Sources */, C4B9B91427E1921B0063535C /* LiveTVNativeVideoPlayerView.swift in Sources */, + E18E021E2887492B0022598C /* Divider.swift in Sources */, E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */, E1A2C15A279A7D76005EC829 /* BundleExtensions.swift in Sources */, C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */, C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */, + E19E550728972B97003CE330 /* BlurHash.swift in Sources */, 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, - E107BB972788104100354E07 /* CinematicCollectionItemView.swift in Sources */, - 53116A17268B919A003024C9 /* SeriesItemView.swift in Sources */, E13DD3F027178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */, + E1A16CA1288A7CFD00EA4679 /* AboutViewCard.swift in Sources */, + E1937A3F288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */, 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, 5D32EA12278C95E30020E292 /* VLCPlayer+subtitles.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, - E1E5D53B2783A80900692DFE /* CinematicItemViewTopRow.swift in Sources */, E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */, + E18E02212887492B0022598C /* MultiSelectorView.swift in Sources */, C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, + E18E02242887492B0022598C /* BlurHashView.swift in Sources */, 62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */, - E1E5D5462783C28100692DFE /* CinematicItemAboutView.swift in Sources */, E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, + E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */, E1EBCB4A278BE443009FE6E9 /* ItemOverviewCoordinator.swift in Sources */, - 53116A19268B947A003024C9 /* PlainLinkButton.swift in Sources */, + E19E550928972B97003CE330 /* StringCoding.swift in Sources */, C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, - 536D3D88267C17350004248C /* PublicUserButton.swift in Sources */, + E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */, + E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, + E1C9260B2887565C002A7A66 /* MovieItemView.swift in Sources */, 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, + E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */, E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, - 53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */, E122A9142788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */, E103A6A3278A7EC400820EC7 /* CinematicBackgroundView.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, - 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */, E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */, E103A6A5278A82E500820EC7 /* HomeCinematicView.swift in Sources */, + E18E021A2887492B0022598C /* AppIcon.swift in Sources */, E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, E126F742278A656C00A522BF /* ServerStreamType.swift in Sources */, - E1E5D5422783B33900692DFE /* PortraitItemsRowView.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, - E14B4142279354770016CBE5 /* LocalizedLookup.swift in Sources */, - E1B59FD92786AE4600A5287E /* NextUpCard.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, - E1E5D5372783A52C00692DFE /* CinematicEpisodeItemView.swift in Sources */, + E1C9261C288756BD002A7A66 /* PortraitPosterHStack.swift in Sources */, C453497F279A2DA50045F1E2 /* LiveTVPlayerViewController.swift in Sources */, C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */, + E1C9261A288756BD002A7A66 /* PortraitButton.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, - 53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */, + E11895AA289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, + E1EF473A289A0F610034046B /* TruncatedTextView.swift in Sources */, + E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */, E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */, + E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */, 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */, 62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */, + E1C925F528875037002A7A66 /* ItemViewType.swift in Sources */, 535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */, 53A83C33268A309300DF3D92 /* LibraryView.swift in Sources */, + E19E550B28972B97003CE330 /* FromString.swift in Sources */, 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 5398514526B64DA100101B49 /* SettingsView.swift in Sources */, 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, E193D54B271941D300900D82 /* ServerListView.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */, + E19E551928972B97003CE330 /* Generation.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, + E19E550F28972B97003CE330 /* ColourProbes.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, + E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */, C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */, E193D53727193F8700900D82 /* LibraryListCoordinator.swift in Sources */, + E18E023C288749540022598C /* UIScrollViewExtensions.swift in Sources */, C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */, - E10D87DF278510E400BD264C /* PosterSize.swift in Sources */, + E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */, E103A6A0278A7E4500820EC7 /* UICinematicBackgroundView.swift in Sources */, - E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */, + E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */, E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */, + E19E551328972B97003CE330 /* ToUIImage.swift in Sources */, E1BDE359278E9ED2004E4022 /* MissingItemsSettingsView.swift in Sources */, E193D54D2719426600900D82 /* LibraryFilterView.swift in Sources */, C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, - E193D4D927193CAC00900D82 /* PortraitImageStackable.swift in Sources */, - 535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */, + E19E551128972B97003CE330 /* ToString.swift in Sources */, C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */, E1D4BF852719D25A00A11E64 /* TrackLanguage.swift in Sources */, - 53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */, - E1047E2127E584AF00CB0D4A /* BlurHashView.swift in Sources */, - 531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */, + E1C926142887565C002A7A66 /* FocusGuide.swift in Sources */, E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */, E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, + E1399475289B1EA900401ABC /* Defaults+Workaround.swift in Sources */, + E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, + E18E02202887492B0022598C /* AttributeFillView.swift in Sources */, + E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, + E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */, E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */, E1A2C156279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */, - E1A2C15E279A7D9F005EC829 /* AppIcon.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, - 535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */, C4BE07862728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, + E18E02222887492B0022598C /* SearchablePickerView.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, + E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */, 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */, 5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */, - E1047E2427E5880000CB0D4A /* InitialFailureView.swift in Sources */, - E1AA33232782648000F6439C /* OverlaySliderColor.swift in Sources */, + E18E021B2887492B0022598C /* SearchBarView.swift in Sources */, E103A6A7278AB6D700820EC7 /* CinematicResumeCardView.swift in Sources */, 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */, - 5398514726B64E4100101B49 /* SearchBarView.swift in Sources */, E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, + E18E021F2887492B0022598C /* InitialFailureView.swift in Sources */, E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */, E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */, + E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */, 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */, - E1E5D5402783B0C000692DFE /* CinematicItemViewTopRowButton.swift in Sources */, - 5398514626B64DBB00101B49 /* SearchablePickerView.swift in Sources */, - 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */, C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */, - E13F26AF278754E300DF4761 /* CinematicSeriesItemView.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 53649AB2269D019100A2D8B7 /* LogManager.swift in Sources */, E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */, E13DD3D6271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */, E193D549271941CC00900D82 /* UserSignInView.swift in Sources */, - 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, + E184C161288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */, E19169CF272514760085832A /* HTTPScheme.swift in Sources */, E13849452780370B0024FB48 /* PlaybackSpeed.swift in Sources */, E1C812CD277AE40A00918266 /* VideoPlayerViewModel.swift in Sources */, C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */, E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */, + E1C926132887565C002A7A66 /* SeriesEpisodesView.swift in Sources */, C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */, E13AD7302798C60F00FDCEE8 /* NativePlayerViewController.swift in Sources */, + E18E02232887492B0022598C /* ImageView.swift in Sources */, E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */, - 53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */, 09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */, E1B59FD52786ADE500A5287E /* ContinueWatchingCard.swift in Sources */, E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */, E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */, E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */, E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, - 62666E3627E5027F00EC0ECD /* Defaults+Workaround.swift in Sources */, E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */, E13DD3C927164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, - 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */, E193D53A27193F9000900D82 /* ServerListCoordinator.swift in Sources */, 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, + E19E551528972B97003CE330 /* FromUIImage.swift in Sources */, + E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, 5321753E2671DE9C005491E6 /* Typings.swift in Sources */, E1C812CA277AE40900918266 /* PlayerOverlayDelegate.swift in Sources */, E1AA33202782639D00F6439C /* OverlayType.swift in Sources */, C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */, E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, + E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */, 6264E88D273850380081A12A /* Strings.swift in Sources */, - 536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */, + E19E550D28972B97003CE330 /* EscapeSequences.swift in Sources */, + E1C926102887565C002A7A66 /* PlayButton.swift in Sources */, E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, - E10C0942278B8DAB009DBF93 /* PortraitItemSize.swift in Sources */, C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, - E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */, - E13F26B12787589300DF4761 /* CinematicSeasonItemView.swift in Sources */, + E18E021C2887492B0022598C /* BlurView.swift in Sources */, E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */, - 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, E10D87E327852FD000BD264C /* EpisodesRowManager.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, + E19E550528972B97003CE330 /* ColourSpace.swift in Sources */, E193D547271941C500900D82 /* UserListView.swift in Sources */, + E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */, E1D4BF7F2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */, - E1BDE35B278EA3A3004E4022 /* EpisodesRowCard.swift in Sources */, + E18E021D2887492B0022598C /* AttributeOutlineView.swift in Sources */, E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */, 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, - 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */, + E1937A62288F32DB00CB80AA /* Poster.swift in Sources */, C4BE0764271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */, + E19E551B28972B97003CE330 /* TupleMaths.swift in Sources */, E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */, - E1A2C160279A7DCA005EC829 /* AboutView.swift in Sources */, + E1A2C160279A7DCA005EC829 /* AboutAppView.swift in Sources */, C4BE076A271FC164003F4AD1 /* TVLibrariesView.swift in Sources */, + E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */, + E11CEB8E28999B4A003E74C7 /* FontExtensions.swift in Sources */, C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */, E13DD3C327164941009D4DAF /* SwiftfinStore.swift in Sources */, 09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */, E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */, + E1C9261B288756BD002A7A66 /* DotHStack.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2410,43 +2604,55 @@ buildActionMask = 2147483647; files = ( 5364F455266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, - E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */, + E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E1D4BF7E2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */, - 62666E3427E5027F00EC0ECD /* Defaults+Workaround.swift in Sources */, 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */, + E18E01DB288747230022598C /* iPadOSEpisodeItemView.swift in Sources */, E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */, 621338932660107500A81A2A /* StringExtensions.swift in Sources */, 62C83B08288C6A630004ED0C /* FontPicker.swift in Sources */, - 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, E122A9132788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */, E1C812C5277A90B200918266 /* URLComponentsExtensions.swift in Sources */, E1EBCB44278BD1CE009FE6E9 /* ItemOverviewCoordinator.swift in Sources */, + E1C925F428875037002A7A66 /* ItemViewType.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */, 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, - 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, - 53F866442687A45F00DCD1D7 /* PortraitItemButton.swift in Sources */, + E11895AF2893840F0042947B /* NavBarOffsetView.swift in Sources */, + E168BD11289A4162001A6922 /* HomeContentView.swift in Sources */, + E18E01AA288746AF0022598C /* RefreshableScrollView.swift in Sources */, + E18E0208288749200022598C /* BlurView.swift in Sources */, + E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */, + 53F866442687A45F00DCD1D7 /* PortraitPosterButton.swift in Sources */, + E19E551A28972B97003CE330 /* TupleMaths.swift in Sources */, + E18E01DF288747230022598C /* iPadOSMovieItemView.swift in Sources */, + E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */, + E19E550C28972B97003CE330 /* EscapeSequences.swift in Sources */, C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */, - E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, - C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */, + E18E01E8288747230022598C /* SeriesItemContentView.swift in Sources */, + E19E550428972B97003CE330 /* ColourSpace.swift in Sources */, + E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */, E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */, - E176DE6D278E30D2001EFD8D /* EpisodeRowCard.swift in Sources */, + E176DE6D278E30D2001EFD8D /* EpisodeCard.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, E10D87E227852FD000BD264C /* EpisodesRowManager.swift in Sources */, - 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, + E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, + E18E01FA288747580022598C /* AboutAppView.swift in Sources */, 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, + E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */, E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, - E14B4141279354770016CBE5 /* LocalizedLookup.swift in Sources */, + E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, E111DE222790BB46008118A3 /* DetectBottomScrollView.swift in Sources */, 5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */, + E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, E1A2C158279A7D76005EC829 /* BundleExtensions.swift in Sources */, C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */, @@ -2454,7 +2660,6 @@ E13AD72E2798BC8D00FDCEE8 /* NativePlayerViewController.swift in Sources */, E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */, C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */, - E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, E19169CE272514760085832A /* HTTPScheme.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, @@ -2463,63 +2668,85 @@ 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, 62C29EA326D1030F00C1D2E7 /* ConnectToServerCoodinator.swift in Sources */, E1047E2027E584AF00CB0D4A /* BlurHashView.swift in Sources */, + E19E551028972B97003CE330 /* ToString.swift in Sources */, + E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */, 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */, - E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */, - 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, + E18E01A9288746AF0022598C /* PortraitPosterHStack.swift in Sources */, + E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, + E18E0204288749200022598C /* Divider.swift in Sources */, + E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */, E1CEFBF527914C7700F60429 /* CustomizeViewsSettings.swift in Sources */, E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, + E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */, E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */, + E18E01EF288747230022598C /* ListDetailsView.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, + E1399474289B1EA900401ABC /* Defaults+Workaround.swift in Sources */, C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */, + E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */, E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, + E19E550A28972B97003CE330 /* FromString.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, - E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, + E11CEB8D28999B4A003E74C7 /* FontExtensions.swift in Sources */, E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */, - E10D87DA2784E4F100BD264C /* ItemViewDetailsView.swift in Sources */, + E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, E1C812C3277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */, + E18E01F0288747230022598C /* AttributeHStack.swift in Sources */, + E19E551228972B97003CE330 /* ToUIImage.swift in Sources */, 6334175B287DDFB9000603CE /* QuickConnectSettingsView.swift in Sources */, + E18E0205288749200022598C /* AppIcon.swift in Sources */, + E168BD10289A4162001A6922 /* HomeView.swift in Sources */, + E18E01AB288746AF0022598C /* PillHStack.swift in Sources */, + E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */, + E18E0207288749200022598C /* AttributeOutlineView.swift in Sources */, E1002B642793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */, - E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */, + E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, + E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, + E168BD15289A4162001A6922 /* HomeErrorView.swift in Sources */, E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */, 5D1603FC278A3D5800D22B99 /* SubtitleSize.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, + E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */, 6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */, + E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, 53649AB1269CFB1900A2D8B7 /* LogManager.swift in Sources */, E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, + E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, + E11895AC289383EE0042947B /* NavBarOffsetModifier.swift in Sources */, E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */, + E18E01DE288747230022598C /* iPadOSSeriesItemView.swift in Sources */, + E19E550828972B97003CE330 /* StringCoding.swift in Sources */, + E19E551428972B97003CE330 /* FromUIImage.swift in Sources */, 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */, + E19E550628972B97003CE330 /* BlurHash.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, E1E48CC9271E6D410021A2F9 /* RefreshHelper.swift in Sources */, 631759CF2879DB6A00A621AD /* PublicUserSignInCellView.swift in Sources */, - E1AA33222782648000F6439C /* OverlaySliderColor.swift in Sources */, E1D4BF842719D25A00A11E64 /* TrackLanguage.swift in Sources */, - E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */, - E1AD106226D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift in Sources */, E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */, + E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */, + E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */, 6220D0C626D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, - E1A2C15C279A7D9F005EC829 /* AppIcon.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, - 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, - E1A2C151279A7008005EC829 /* AboutView.swift in Sources */, - E10D87DC2784EC5200BD264C /* EpisodesRowView.swift in Sources */, + E10D87DC2784EC5200BD264C /* SeriesEpisodesView.swift in Sources */, E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */, - E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */, - E10C0941278B8DAB009DBF93 /* PortraitItemSize.swift in Sources */, + E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */, + E18E01E3288747230022598C /* CompactPortraitScrollView.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, @@ -2527,17 +2754,25 @@ E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */, C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */, E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */, + E18E01EE288747230022598C /* AboutView.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, + E11CEB8B28998552003E74C7 /* iOSViewExtensions.swift in Sources */, E193D4DB27193CCA00900D82 /* PillStackable.swift in Sources */, + E18E0206288749200022598C /* AttributeFillView.swift in Sources */, E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E1A2C154279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */, - E193D4D827193CAC00900D82 /* PortraitImageStackable.swift in Sources */, + E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */, C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */, + E184C160288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */, + E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */, + E18E01E9288747230022598C /* SeriesItemView.swift in Sources */, 624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */, + E18E023A288749540022598C /* UIScrollViewExtensions.swift in Sources */, E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */, E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */, 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, + E1937A3E288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */, E176DE70278E369F001EFD8D /* MissingItemsSettingsView.swift in Sources */, C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, @@ -2552,27 +2787,29 @@ E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */, E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */, + E18E01F1288747230022598C /* PlayButton.swift in Sources */, E13DD3D5271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */, 62E1DCC3273CE19800C9AE76 /* URLExtensions.swift in Sources */, E1267D3E271A1F46003C492E /* PreferenceUIHostingController.swift in Sources */, + E19E551828972B97003CE330 /* Generation.swift in Sources */, + E18E01E6288747230022598C /* CollectionItemView.swift in Sources */, 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */, E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, - 6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */, + E19E550E28972B97003CE330 /* ColourProbes.swift in Sources */, E1E00A35278628A40022235B /* DoubleExtensions.swift in Sources */, 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, - 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */, E126F741278A656C00A522BF /* ServerStreamType.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, + E18E01EA288747230022598C /* MovieItemView.swift in Sources */, + E168BD12289A4162001A6922 /* ContinueWatchingCard.swift in Sources */, 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, E1D4BF872719D27100A11E64 /* Bitrates.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, - 62553429282190A00087FE20 /* PanDirectionGestureRecognizer.swift in Sources */, E13DD3EF27178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, - E10D87DE278510E400BD264C /* PosterSize.swift in Sources */, E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, @@ -2580,56 +2817,8 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 628B951C2670CABD0091AF3B /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 53649AB3269D3F5B00A2D8B7 /* LogManager.swift in Sources */, - E13DD3CB27164BA8009D4DAF /* UIDeviceExtensions.swift in Sources */, - 6264E88A27384A6F0081A12A /* NetworkError.swift in Sources */, - E19169D0272514760085832A /* HTTPScheme.swift in Sources */, - E1E00A37278628A40022235B /* DoubleExtensions.swift in Sources */, - 6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */, - 628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */, - E1A2C155279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */, - 6267B3DB2671139400A7371D /* ImageExtensions.swift in Sources */, - E1AD105926D9A543003E4A08 /* LazyView.swift in Sources */, - E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */, - 628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */, - 6264E88E273850380081A12A /* Strings.swift in Sources */, - E10D87E0278510E400BD264C /* PosterSize.swift in Sources */, - E1AD105426D97161003E4A08 /* BaseItemDtoExtensions.swift in Sources */, - E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */, - E14B4143279354770016CBE5 /* LocalizedLookup.swift in Sources */, - 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */, - E13DD3F72717E87D009D4DAF /* SwiftfinNotificationCenter.swift in Sources */, - E1D4BF8D2719F3A300A11E64 /* VideoPlayerJumpLength.swift in Sources */, - 6220D0AF26D5EABE00B8E046 /* ViewExtensions.swift in Sources */, - E13DD3D7271693CD009D4DAF /* SwiftfinStoreDefaults.swift in Sources */, - E13DD3CA27164B80009D4DAF /* SwiftfinStore.swift in Sources */, - E10EAA51277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, - 62E1DCC5273CE19800C9AE76 /* URLExtensions.swift in Sources */, - 62EC353226766849000E9F2D /* SessionManager.swift in Sources */, - E1A2C159279A7D76005EC829 /* BundleExtensions.swift in Sources */, - 536D3D79267BD5D00004248C /* ViewModel.swift in Sources */, - 5D1603FE278A40DC00D22B99 /* SubtitleSize.swift in Sources */, - E1AA332427829B5200F6439C /* OverlayType.swift in Sources */, - E1D4BF8C2719F39F00A11E64 /* AppAppearance.swift in Sources */, - E1A2C15D279A7D9F005EC829 /* AppIcon.swift in Sources */, - 62666E3527E5027F00EC0ECD /* Defaults+Workaround.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - 6264E889273848760081A12A /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 628B951F2670CABD0091AF3B /* Swiftfin Widget */; - targetProxy = 6264E888273848760081A12A /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - /* Begin PBXVariantGroup section */ 534D4FE726A7D7CC000A7A48 /* Localizable.strings */ = { isa = PBXVariantGroup; @@ -3010,57 +3199,6 @@ }; name = Release; }; - 628B952F2670CABE0091AF3B /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 70; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = WidgetExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin.widget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 628B95302670CABE0091AF3B /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 70; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = WidgetExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin.widget; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -3091,15 +3229,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 628B952E2670CABE0091AF3B /* Build configuration list for PBXNativeTarget "Swiftfin Widget" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 628B952F2670CABE0091AF3B /* Debug */, - 628B95302670CABE0091AF3B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -3111,14 +3240,6 @@ minimumVersion = 0.1.3; }; }; - 536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/PGSSoft/ParallaxView"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.0.0; - }; - }; 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/duyquang91/ActivityIndicator"; @@ -3260,16 +3381,6 @@ package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; productName = Introspect; }; - 536D3D7C267BD5F90004248C /* ActivityIndicator */ = { - isa = XCSwiftPackageProductDependency; - package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; - productName = ActivityIndicator; - }; - 536D3D83267BEA550004248C /* ParallaxView */ = { - isa = XCSwiftPackageProductDependency; - package = 536D3D82267BEA550004248C /* XCRemoteSwiftPackageReference "ParallaxView" */; - productName = ParallaxView; - }; 53ABFDEC26799D7700886593 /* ActivityIndicator */ = { isa = XCSwiftPackageProductDependency; package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; @@ -3325,11 +3436,6 @@ package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; productName = JellyfinAPI; }; - E10EAA46277BB670000269ED /* JellyfinAPI */ = { - isa = XCSwiftPackageProductDependency; - package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; - productName = JellyfinAPI; - }; E10EAA4C277BB716000269ED /* Sliders */ = { isa = XCSwiftPackageProductDependency; package = E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */; @@ -3360,11 +3466,6 @@ package = E1347DB0279E3C6200BC6161 /* XCRemoteSwiftPackageReference "Puppy" */; productName = Puppy; }; - E1347DB3279E3C9E00BC6161 /* Puppy */ = { - isa = XCSwiftPackageProductDependency; - package = E1347DB0279E3C6200BC6161 /* XCRemoteSwiftPackageReference "Puppy" */; - productName = Puppy; - }; E1347DB5279E3CA500BC6161 /* Puppy */ = { isa = XCSwiftPackageProductDependency; package = E1347DB0279E3C6200BC6161 /* XCRemoteSwiftPackageReference "Puppy" */; @@ -3385,21 +3486,11 @@ package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; productName = CoreStore; }; - E13DD3CE27164E1F009D4DAF /* CoreStore */ = { - isa = XCSwiftPackageProductDependency; - package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */; - productName = CoreStore; - }; E13DD3D227168E65009D4DAF /* Defaults */ = { isa = XCSwiftPackageProductDependency; package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; - E13DD3DC27175CE3009D4DAF /* Defaults */ = { - isa = XCSwiftPackageProductDependency; - package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; - productName = Defaults; - }; E178857C278037FD0094FBCF /* JellyfinAPI */ = { isa = XCSwiftPackageProductDependency; package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; @@ -3425,11 +3516,6 @@ package = E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */; productName = SwiftyJSON; }; - E1D7E5A727892566009D0EF7 /* Nuke */ = { - isa = XCSwiftPackageProductDependency; - package = E1AE8E7A2789135A00FBDDAA /* XCRemoteSwiftPackageReference "Nuke" */; - productName = Nuke; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5377CBE9263B596A003A4E83 /* Project object */; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d09e4d87..82b35677 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,15 +99,6 @@ "version" : "0.8.3" } }, - { - "identity" : "parallaxview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/PGSSoft/ParallaxView", - "state" : { - "revision" : "a4165b0edd9c9c923a1d6e3e4c9a807302a1a475", - "version" : "3.1.2" - } - }, { "identity" : "puppy", "kind" : "remoteSourceControl", diff --git a/Swiftfin/App/JellyfinPlayerApp.swift b/Swiftfin/App/JellyfinPlayerApp.swift index 0dfcfae6..24234776 100644 --- a/Swiftfin/App/JellyfinPlayerApp.swift +++ b/Swiftfin/App/JellyfinPlayerApp.swift @@ -24,7 +24,10 @@ struct JellyfinPlayerApp: App { EmptyView() .ignoresSafeArea() .withHostingWindow { window in - window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view()) + window?.rootViewController = PreferenceUIHostingController { + MainCoordinator() + .view() + } } .onAppear { JellyfinPlayerApp.setupAppearance() @@ -63,3 +66,10 @@ extension View { background(HostingWindowFinder(callback: callback)) } } + +extension UINavigationController { + // Remove back button text + override open func viewWillLayoutSubviews() { + navigationBar.topItem?.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) + } +} diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift index 5b89490b..abc2aa4a 100644 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift +++ b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift @@ -12,10 +12,10 @@ import UIKit // MARK: PreferenceUIHostingController class PreferenceUIHostingController: UIHostingController { - init(wrappedView: V) { + init(@ViewBuilder wrappedView: @escaping () -> V) { let box = Box() super.init(rootView: AnyView( - wrappedView + wrappedView() .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { box.value?._prefersHomeIndicatorAutoHidden = $0 }.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift index dacfa882..5fb925fa 100644 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift +++ b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift @@ -25,7 +25,7 @@ struct PreferenceUIHostingControllerView: UIViewControllerReprese var wrappedView: () -> Wrapped func makeUIViewController(context: Context) -> PreferenceUIHostingController { - PreferenceUIHostingController(wrappedView: wrappedView()) + PreferenceUIHostingController { wrappedView() } } func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} diff --git a/Swiftfin/Components/DotHStack.swift b/Swiftfin/Components/DotHStack.swift new file mode 100644 index 00000000..703b5802 --- /dev/null +++ b/Swiftfin/Components/DotHStack.swift @@ -0,0 +1,235 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// TODO: Check for if statements, look at ViewBuilder's buildIf + +struct DotHStack: View { + + private let items: [AnyView] + private let restItems: [AnyView] + private let alignment: HorizontalAlignment + + var body: some View { + HStack { + items.first + + ForEach(0 ..< restItems.count, id: \.self) { i in + + Circle() + .frame(width: 2, height: 2) + + restItems[i] + } + } + } +} + +extension DotHStack { + + init( + _ data: Data, + id: KeyPath = \.self, + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: @escaping (Data.Element) -> Content + ) { + self.alignment = alignment + self.items = data.map { content($0[keyPath: id]).eraseToAnyView() } + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> A + ) { + self.alignment = alignment + self.items = [content().eraseToAnyView()] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B)> + ) { + self.alignment = alignment + let _content = content() + + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C)> + ) { + self.alignment = alignment + let _content = content() + + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C, D)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C, D, E)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C, D, E, F)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () -> TupleView<(A, B, C, D, E, F, G)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + _content.value.6.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () + -> TupleView<(A, B, C, D, E, F, G, H)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + _content.value.6.eraseToAnyView(), + _content.value.7.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () + -> TupleView<(A, B, C, D, E, F, G, H, I)> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + _content.value.6.eraseToAnyView(), + _content.value.7.eraseToAnyView(), + _content.value.8.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } + + init< + A: View, + B: View, + C: View, + D: View, + E: View, + F: View, + G: View, + H: View, + I: View, + J: View + >( + alignment: HorizontalAlignment = .leading, + @ViewBuilder content: () + -> TupleView<( + A, + B, + C, + D, + E, + F, + G, + H, + I, + J + )> + ) { + self.alignment = alignment + let _content = content() + self.items = [ + _content.value.0.eraseToAnyView(), + _content.value.1.eraseToAnyView(), + _content.value.2.eraseToAnyView(), + _content.value.3.eraseToAnyView(), + _content.value.4.eraseToAnyView(), + _content.value.5.eraseToAnyView(), + _content.value.6.eraseToAnyView(), + _content.value.7.eraseToAnyView(), + _content.value.8.eraseToAnyView(), + _content.value.9.eraseToAnyView(), + ] + self.restItems = Array(items.dropFirst()) + } +} diff --git a/Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift b/Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift deleted file mode 100644 index 97a3ef51..00000000 --- a/Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct EpisodeRowCard: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - let viewModel: EpisodesRowManager - let episode: BaseItemDto - - var body: some View { - Button { - itemRouter.route(to: \.item, episode) - } label: { - HStack(alignment: .top) { - VStack(alignment: .leading) { - - ImageView( - episode.getBackdropImage(maxWidth: 200), - blurHash: episode.getBackdropImageBlurHash() - ) - .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) - .frame(width: 200, height: 112) - .overlay { - if episode.id == viewModel.item.id { - RoundedRectangle(cornerRadius: 6) - .stroke(Color.jellyfinPurple, lineWidth: 4) - } - } - .padding(.top) - .accessibilityIgnoresInvertColors() - - VStack(alignment: .leading) { - Text(episode.getEpisodeLocator() ?? "S-:E-") - .font(.footnote) - .foregroundColor(.secondary) - Text(episode.name ?? L10n.noTitle) - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) - - if episode.unaired { - Text(episode.airDateLabel ?? L10n.noOverviewAvailable) - .font(.caption) - .foregroundColor(.secondary) - .fontWeight(.light) - .lineLimit(3) - } else { - Text(episode.overview ?? "") - .font(.caption) - .foregroundColor(.secondary) - .fontWeight(.light) - .lineLimit(3) - } - } - - Spacer() - } - .frame(width: 200) - .shadow(radius: 4, y: 2) - } - } - .buttonStyle(PlainButtonStyle()) - } -} diff --git a/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift b/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift deleted file mode 100644 index 13dcb0fe..00000000 --- a/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct EpisodesRowView: View where RowManager: EpisodesRowManager { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: RowManager - let onlyCurrentSeason: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - - HStack { - - if onlyCurrentSeason { - if let currentSeason = Array(viewModel.seasonsEpisodes.keys).first(where: { $0.id == viewModel.item.id }) { - Text(currentSeason.name ?? L10n.noTitle) - .accessibility(addTraits: [.isHeader]) - } - } else { - Menu { - ForEach( - viewModel.sortedSeasons, - id: \.self - ) { season in - Button { - viewModel.select(season: season) - } label: { - if season.id == viewModel.selectedSeason?.id { - Label(season.name ?? L10n.season, systemImage: "checkmark") - } else { - Text(season.name ?? L10n.season) - } - } - } - } label: { - HStack(spacing: 5) { - Text(viewModel.selectedSeason?.name ?? L10n.unknown) - .fontWeight(.semibold) - .fixedSize() - Image(systemName: "chevron.down") - } - } - } - - Spacer() - } - .padding() - - ScrollView(.horizontal, showsIndicators: false) { - ScrollViewReader { reader in - HStack(alignment: .top, spacing: 15) { - if viewModel.isLoading { - VStack(alignment: .leading) { - - ZStack { - Color.gray.ignoresSafeArea() - - ProgressView() - } - .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) - .frame(width: 200, height: 112) - - VStack(alignment: .leading) { - Text("S-:E-") - .font(.footnote) - .foregroundColor(.secondary) - Text("--") - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) - } - - Spacer() - } - .frame(width: 200) - .shadow(radius: 4, y: 2) - } else if let selectedSeason = viewModel.selectedSeason { - if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { - if seasonEpisodes.isEmpty { - VStack(alignment: .leading) { - - Color.gray.ignoresSafeArea() - .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) - .frame(width: 200, height: 112) - - VStack(alignment: .leading) { - Text("--") - .font(.footnote) - .foregroundColor(.secondary) - - L10n.noEpisodesAvailable.text - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) - } - - Spacer() - } - .frame(width: 200) - .shadow(radius: 4, y: 2) - } else { - ForEach(seasonEpisodes, id: \.self) { episode in - EpisodeRowCard(viewModel: viewModel, episode: episode) - .id(episode.id) - } - } - } - } - } - .padding(.horizontal) - .onChange(of: viewModel.selectedSeason) { _ in - if viewModel.selectedSeason?.id == viewModel.item.seasonId { - reader.scrollTo(viewModel.item.id) - } - } - .onChange(of: viewModel.seasonsEpisodes) { _ in - if viewModel.selectedSeason?.id == viewModel.item.seasonId { - reader.scrollTo(viewModel.item.id) - } - } - } - .edgesIgnoringSafeArea(.horizontal) - } - } - } -} diff --git a/Swiftfin/Components/PillHStackView.swift b/Swiftfin/Components/PillHStack.swift similarity index 70% rename from Swiftfin/Components/PillHStackView.swift rename to Swiftfin/Components/PillHStack.swift index 4ae9f8b4..e9bef9d1 100644 --- a/Swiftfin/Components/PillHStackView.swift +++ b/Swiftfin/Components/PillHStack.swift @@ -8,20 +8,22 @@ import SwiftUI -struct PillHStackView: View { +struct PillHStack: View { let title: String - let items: [ItemType] - let selectedAction: (ItemType) -> Void + let items: [Item] + let selectedAction: (Item) -> Void var body: some View { VStack(alignment: .leading) { Text(title) - .font(.callout) + .font(.title2) .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) .accessibility(addTraits: [.isHeader]) + .padding(.leading) + .if(UIDevice.isIPad) { view in + view.padding(.leading) + } ScrollView(.horizontal, showsIndicators: false) { HStack { @@ -39,17 +41,16 @@ struct PillHStackView: View { .fontWeight(.semibold) .foregroundColor(.primary) .fixedSize() - .padding(.leading, 10) - .padding(.trailing, 10) - .padding(.top, 10) - .padding(.bottom, 10) + .padding(10) } .fixedSize() } } } - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + .padding(.horizontal) + .if(UIDevice.isIPad) { view in + view.padding(.horizontal) + } } } } diff --git a/Swiftfin/Components/PortraitHStackView.swift b/Swiftfin/Components/PortraitHStackView.swift deleted file mode 100644 index 41b64c8c..00000000 --- a/Swiftfin/Components/PortraitHStackView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct PortraitImageHStackView: View { - - let items: [ItemType] - let maxWidth: CGFloat - let horizontalAlignment: HorizontalAlignment - let textAlignment: TextAlignment - let topBarView: () -> TopBarView - let selectedAction: (ItemType) -> Void - - init( - items: [ItemType], - maxWidth: CGFloat = 110, - horizontalAlignment: HorizontalAlignment = .leading, - textAlignment: TextAlignment = .leading, - topBarView: @escaping () -> TopBarView, - selectedAction: @escaping (ItemType) -> Void - ) { - self.items = items - self.maxWidth = maxWidth - self.horizontalAlignment = horizontalAlignment - self.textAlignment = textAlignment - self.topBarView = topBarView - self.selectedAction = selectedAction - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - topBarView() - - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 15) { - ForEach(items, id: \.self.portraitImageID) { item in - Button { - selectedAction(item) - } label: { - VStack(alignment: horizontalAlignment) { - ImageView( - item.imageURLConstructor(maxWidth: Int(maxWidth)), - blurHash: item.blurHash, - failureView: { - InitialFailureView(item.failureInitials) - } - ) - .portraitPoster(width: maxWidth) - .shadow(radius: 4, y: 2) - .accessibilityIgnoresInvertColors() - - if item.showTitle { - Text(item.title) - .font(.footnote) - .fontWeight(.regular) - .foregroundColor(.primary) - .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } - - if let description = item.subtitle { - Text(description) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } - } - .frame(width: maxWidth) - } - .padding(.bottom) - } - } - .padding(.horizontal) - } - } - } -} diff --git a/Swiftfin/Components/PortraitItemButton.swift b/Swiftfin/Components/PortraitPosterButton.swift similarity index 74% rename from Swiftfin/Components/PortraitItemButton.swift rename to Swiftfin/Components/PortraitPosterButton.swift index 2e4aeda4..e56215d2 100644 --- a/Swiftfin/Components/PortraitItemButton.swift +++ b/Swiftfin/Components/PortraitPosterButton.swift @@ -6,23 +6,25 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // -import JellyfinAPI import SwiftUI -struct PortraitItemButton: View { +struct PortraitPosterButton: View { - let item: ItemType + @Environment(\.colorScheme) + private var colorScheme + + let item: Item let maxWidth: CGFloat let horizontalAlignment: HorizontalAlignment let textAlignment: TextAlignment - let selectedAction: (ItemType) -> Void + let selectedAction: (Item) -> Void init( - item: ItemType, + item: Item, maxWidth: CGFloat = 110, horizontalAlignment: HorizontalAlignment = .leading, textAlignment: TextAlignment = .leading, - selectedAction: @escaping (ItemType) -> Void + selectedAction: @escaping (Item) -> Void ) { self.item = item self.maxWidth = maxWidth @@ -37,14 +39,12 @@ struct PortraitItemButton: View { } label: { VStack(alignment: horizontalAlignment) { ImageView( - item.imageURLConstructor(maxWidth: Int(maxWidth)), - blurHash: item.blurHash, + item.portraitPosterImageSource(maxWidth: maxWidth), failureView: { - InitialFailureView(item.failureInitials) + InitialFailureView(item.title.initials) } ) .portraitPoster(width: maxWidth) - .shadow(radius: 4, y: 2) .accessibilityIgnoresInvertColors() if item.showTitle { @@ -53,7 +53,6 @@ struct PortraitItemButton: View { .fontWeight(.regular) .foregroundColor(.primary) .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) .lineLimit(2) } @@ -63,13 +62,13 @@ struct PortraitItemButton: View { .fontWeight(.medium) .foregroundColor(.secondary) .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) .lineLimit(2) } } .frame(width: maxWidth) } - .frame(alignment: .top) - .padding(.bottom) + .if(colorScheme == .light) { view in + view.shadow(radius: 4, y: 2) + } } } diff --git a/Swiftfin/Components/PortraitPosterHStack.swift b/Swiftfin/Components/PortraitPosterHStack.swift new file mode 100644 index 00000000..e7a31763 --- /dev/null +++ b/Swiftfin/Components/PortraitPosterHStack.swift @@ -0,0 +1,84 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct PortraitPosterHStack: View { + + private let title: String + private let items: [Item] + private let itemWidth: CGFloat + private let trailingContent: () -> TrailingContent + private let selectedAction: (Item) -> Void + + init( + title: String, + items: [Item], + itemWidth: CGFloat = 110, + @ViewBuilder trailingContent: @escaping () -> TrailingContent, + selectedAction: @escaping (Item) -> Void + ) { + self.title = title + self.items = items + self.itemWidth = itemWidth + self.trailingContent = trailingContent + self.selectedAction = selectedAction + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + .padding(.leading) + .if(UIDevice.isIPad) { view in + view.padding(.leading) + } + + Spacer() + + trailingContent() + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 15) { + ForEach(items, id: \.hashValue) { item in + PortraitPosterButton( + item: item, + maxWidth: itemWidth, + horizontalAlignment: .leading + ) { item in + selectedAction(item) + } + } + } + .padding(.horizontal) + .if(UIDevice.isIPad) { view in + view.padding(.horizontal) + } + } + } + } +} + +extension PortraitPosterHStack where TrailingContent == EmptyView { + init( + title: String, + items: [Item], + itemWidth: CGFloat = 110, + selectedAction: @escaping (Item) -> Void + ) { + self.title = title + self.items = items + self.itemWidth = itemWidth + self.trailingContent = { EmptyView() } + self.selectedAction = selectedAction + } +} diff --git a/Swiftfin/Components/PrimaryButtonView.swift b/Swiftfin/Components/PrimaryButton.swift similarity index 76% rename from Swiftfin/Components/PrimaryButtonView.swift rename to Swiftfin/Components/PrimaryButton.swift index 6fd9b752..fba33235 100644 --- a/Swiftfin/Components/PrimaryButtonView.swift +++ b/Swiftfin/Components/PrimaryButton.swift @@ -8,7 +8,7 @@ import SwiftUI -struct PrimaryButtonView: View { +struct PrimaryButton: View { private let title: String private let action: () -> Void @@ -24,12 +24,10 @@ struct PrimaryButtonView: View { } label: { ZStack { Rectangle() - .foregroundColor(Color(UIColor.systemPurple)) - .frame(maxWidth: 400, maxHeight: 50) + .foregroundColor(Color.jellyfinPurple) + .frame(maxWidth: 400) .frame(height: 50) .cornerRadius(10) - .padding(.horizontal, 30) - .padding([.top, .bottom], 20) Text(title) .foregroundColor(Color.white) diff --git a/Swiftfin/Components/RefreshableScrollView.swift b/Swiftfin/Components/RefreshableScrollView.swift new file mode 100644 index 00000000..12857675 --- /dev/null +++ b/Swiftfin/Components/RefreshableScrollView.swift @@ -0,0 +1,33 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Introspect +import SwiftUI + +struct RefreshableScrollView: View { + + let content: () -> Content + let onRefresh: () -> Void + + private let refreshHelper = RefreshHelper() + + var body: some View { + ScrollView(showsIndicators: false) { + content() + } + .introspectScrollView { scrollView in + let control = UIRefreshControl() + + refreshHelper.refreshControl = control + refreshHelper.refreshAction = onRefresh + + control.addTarget(refreshHelper, action: #selector(RefreshHelper.didRefresh), for: .valueChanged) + scrollView.refreshControl = control + } + } +} diff --git a/Swiftfin/Components/TruncatedTextView.swift b/Swiftfin/Components/TruncatedTextView.swift deleted file mode 100644 index 54195865..00000000 --- a/Swiftfin/Components/TruncatedTextView.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct TruncatedTextView: View { - - @State - private var truncated: Bool = false - @State - private var shrinkText: String - private var text: String - let font: UIFont - let lineLimit: Int - let seeMoreAction: () -> Void - - private var moreLessText: String { - if !truncated { - return "" - } else { - return L10n.seeMore - } - } - - init( - _ text: String, - lineLimit: Int, - font: UIFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body), - seeMoreAction: @escaping () -> Void - ) { - self.text = text - self.lineLimit = lineLimit - _shrinkText = State(wrappedValue: text) - self.font = font - self.seeMoreAction = seeMoreAction - } - - var body: some View { - VStack(alignment: .center) { - Group { - Text(shrinkText) - .overlay { - if truncated { - LinearGradient( - stops: [ - .init(color: .systemBackground.opacity(0), location: 0.5), - .init(color: .systemBackground.opacity(0.8), location: 0.7), - .init(color: .systemBackground, location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - } - } - } - .lineLimit(lineLimit) - .background { - // Render the limited text and measure its size - Text(text) - .lineLimit(lineLimit + 2) - .background { - GeometryReader { visibleTextGeometry in - Color.clear - .onAppear { - let size = CGSize(width: visibleTextGeometry.size.width, height: .greatestFiniteMagnitude) - let attributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: font] - var low = 0 - var heigh = shrinkText.count - var mid = heigh - while (heigh - low) > 1 { - let attributedText = NSAttributedString(string: shrinkText, attributes: attributes) - let boundingRect = attributedText.boundingRect( - with: size, - options: NSStringDrawingOptions - .usesLineFragmentOrigin, - context: nil - ) - if boundingRect.size.height > visibleTextGeometry.size.height { - truncated = true - heigh = mid - mid = (heigh + low) / 2 - - } else { - if mid == text.count { - break - } else { - low = mid - mid = (low + heigh) / 2 - } - } - shrinkText = String(text.prefix(mid)) - } - - if truncated { - shrinkText = String(shrinkText.prefix(shrinkText.count - 2)) - } - } - } - } - .hidden() - } - .font(Font(font)) - - if truncated { - Button { - seeMoreAction() - } label: { - Text(moreLessText) - } - } - } - } -} diff --git a/Swiftfin/Extensions/iOSViewExtensions/NavBarOffsetModifier/NavBarOffsetModifier.swift b/Swiftfin/Extensions/iOSViewExtensions/NavBarOffsetModifier/NavBarOffsetModifier.swift new file mode 100644 index 00000000..65534c9f --- /dev/null +++ b/Swiftfin/Extensions/iOSViewExtensions/NavBarOffsetModifier/NavBarOffsetModifier.swift @@ -0,0 +1,25 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct NavBarOffsetModifier: ViewModifier { + + @Binding + var scrollViewOffset: CGFloat + + let start: CGFloat + let end: CGFloat + + func body(content: Content) -> some View { + NavBarOffsetView(scrollViewOffset: $scrollViewOffset, start: start, end: end) { + content + } + .ignoresSafeArea() + } +} diff --git a/Swiftfin/Extensions/iOSViewExtensions/NavBarOffsetModifier/NavBarOffsetView.swift b/Swiftfin/Extensions/iOSViewExtensions/NavBarOffsetModifier/NavBarOffsetView.swift new file mode 100644 index 00000000..0f015eda --- /dev/null +++ b/Swiftfin/Extensions/iOSViewExtensions/NavBarOffsetModifier/NavBarOffsetView.swift @@ -0,0 +1,94 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct NavBarOffsetView: UIViewControllerRepresentable { + + @Binding + private var scrollViewOffset: CGFloat + + private let start: CGFloat + private let end: CGFloat + private let content: () -> Content + + init(scrollViewOffset: Binding, start: CGFloat, end: CGFloat, @ViewBuilder content: @escaping () -> Content) { + self._scrollViewOffset = scrollViewOffset + self.start = start + self.end = end + self.content = content + } + + init(start: CGFloat, end: CGFloat, @ViewBuilder body: @escaping () -> Content) { + self._scrollViewOffset = Binding(get: { 0 }, set: { _ in }) + self.start = start + self.end = end + self.content = body + } + + func makeUIViewController(context: Context) -> UINavBarOffsetHostingController { + UINavBarOffsetHostingController(rootView: content()) + } + + func updateUIViewController(_ uiViewController: UINavBarOffsetHostingController, context: Context) { + uiViewController.scrollViewDidScroll(scrollViewOffset, start: start, end: end) + } +} + +class UINavBarOffsetHostingController: UIHostingController { + + private var lastScrollViewOffset: CGFloat = 0 + + private lazy var navBarBlurView: UIVisualEffectView = { + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial)) + blurView.translatesAutoresizingMaskIntoConstraints = false + return blurView + }() + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = nil + + view.addSubview(navBarBlurView) + navBarBlurView.alpha = 0 + + NSLayoutConstraint.activate([ + navBarBlurView.topAnchor.constraint(equalTo: view.topAnchor), + navBarBlurView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + navBarBlurView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navBarBlurView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + } + + func scrollViewDidScroll(_ offset: CGFloat, start: CGFloat, end: CGFloat) { + let diff = end - start + let currentProgress = (offset - start) / diff + let offset = min(max(currentProgress, 0), 1) + + self.navigationController?.navigationBar + .titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label.withAlphaComponent(offset)] + navBarBlurView.alpha = offset + lastScrollViewOffset = offset + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.navigationBar + .titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label.withAlphaComponent(lastScrollViewOffset)] + self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) + self.navigationController?.navigationBar.shadowImage = UIImage() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.navigationController?.navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label] + self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default) + self.navigationController?.navigationBar.shadowImage = nil + } +} diff --git a/Swiftfin/Extensions/iOSViewExtensions/iOSViewExtensions.swift b/Swiftfin/Extensions/iOSViewExtensions/iOSViewExtensions.swift new file mode 100644 index 00000000..3ebdf829 --- /dev/null +++ b/Swiftfin/Extensions/iOSViewExtensions/iOSViewExtensions.swift @@ -0,0 +1,15 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension View { + func navBarOffset(_ scrollViewOffset: Binding, start: CGFloat, end: CGFloat) -> some View { + self.modifier(NavBarOffsetModifier(scrollViewOffset: scrollViewOffset, start: start, end: end)) + } +} diff --git a/Swiftfin/Views/AboutView.swift b/Swiftfin/Views/AboutAppView.swift similarity index 99% rename from Swiftfin/Views/AboutView.swift rename to Swiftfin/Views/AboutAppView.swift index 681a1882..1ed8d379 100644 --- a/Swiftfin/Views/AboutView.swift +++ b/Swiftfin/Views/AboutAppView.swift @@ -8,7 +8,7 @@ import SwiftUI -struct AboutView: View { +struct AboutAppView: View { var body: some View { List { diff --git a/Swiftfin/Views/BasicAppSettingsView.swift b/Swiftfin/Views/BasicAppSettingsView.swift index 124f1ed0..6b9615fb 100644 --- a/Swiftfin/Views/BasicAppSettingsView.swift +++ b/Swiftfin/Views/BasicAppSettingsView.swift @@ -13,7 +13,7 @@ import SwiftUI struct BasicAppSettingsView: View { @EnvironmentObject - var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router + private var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router @ObservedObject var viewModel: BasicAppSettingsViewModel @State diff --git a/Swiftfin/Views/ContinueWatchingView.swift b/Swiftfin/Views/ContinueWatchingView.swift deleted file mode 100644 index 2fcac8e7..00000000 --- a/Swiftfin/Views/ContinueWatchingView.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct ContinueWatchingView: View { - - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @ObservedObject - var viewModel: HomeViewModel - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 20) { - ForEach(viewModel.resumeItems, id: \.id) { item in - - Button { - homeRouter.route(to: \.item, item) - } label: { - VStack(alignment: .leading) { - - ZStack { - Group { - if item.itemType == .episode { - ImageView(sources: [ - item.getSeriesThumbImage(maxWidth: 320), - item.getSeriesBackdropImage(maxWidth: 320), - ]) - .frame(width: 320, height: 180) - } else { - ImageView(sources: [ - item.getThumbImage(maxWidth: 320), - item.getBackdropImage(maxWidth: 320), - ]) - .frame(width: 320, height: 180) - } - } - .accessibilityIgnoresInvertColors() - - HStack { - VStack { - - Spacer() - - ZStack(alignment: .bottom) { - - LinearGradient( - colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 35) - - VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? L10n.continue) - .font(.subheadline) - .padding(.bottom, 5) - .padding(.leading, 10) - .foregroundColor(.white) - - HStack { - Color.jellyfinPurple - .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) - - Spacer(minLength: 0) - } - } - } - } - } - } - .frame(width: 320, height: 180) - .mask(Rectangle().cornerRadius(10)) - .shadow(radius: 4, y: 2) - - VStack(alignment: .leading) { - Text("\(item.seriesName ?? item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - - if item.itemType == .episode { - Text(item.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - } - .contextMenu { - Button(role: .destructive) { - viewModel.removeItemFromResume(item) - } label: { - Label(L10n.removeFromResume, systemImage: "minus.circle") - } - } - } - } - .padding(.horizontal) - } - } -} diff --git a/Swiftfin/Views/HomeView.swift b/Swiftfin/Views/HomeView.swift deleted file mode 100644 index b1f8f02b..00000000 --- a/Swiftfin/Views/HomeView.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Foundation -import Introspect -import SwiftUI - -struct HomeView: View { - - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @StateObject - var viewModel = HomeViewModel() - - private let refreshHelper = RefreshHelper() - - @ViewBuilder - var innerBody: some View { - if let errorMessage = viewModel.errorMessage { - VStack(spacing: 5) { - if viewModel.isLoading { - ProgressView() - .frame(width: 100, height: 100) - .scaleEffect(2) - } else { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 72)) - .foregroundColor(Color.red) - .frame(width: 100, height: 100) - } - - Text("\(errorMessage.code)") - Text(errorMessage.message) - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) - - PrimaryButtonView(title: L10n.retry) { - viewModel.refresh() - } - } - .offset(y: -50) - } else if viewModel.isLoading { - ProgressView() - .frame(width: 100, height: 100) - .scaleEffect(2) - } else { - ScrollView { - VStack(alignment: .leading) { - if !viewModel.resumeItems.isEmpty { - ContinueWatchingView(viewModel: viewModel) - } - - if !viewModel.nextUpItems.isEmpty { - PortraitImageHStackView( - items: viewModel.nextUpItems, - horizontalAlignment: .leading - ) { - L10n.nextUp.text - .font(.title2) - .fontWeight(.bold) - .padding() - .accessibility(addTraits: [.isHeader]) - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } - } - - if !viewModel.latestAddedItems.isEmpty { - PortraitImageHStackView(items: viewModel.latestAddedItems) { - L10n.recentlyAdded.text - .font(.title2) - .fontWeight(.bold) - .padding() - .accessibility(addTraits: [.isHeader]) - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } - } - - ForEach(viewModel.libraries, id: \.self) { library in - - LatestMediaView(viewModel: LatestMediaViewModel(library: library)) { - HStack { - Text(L10n.latestWithString(library.name ?? "")) - .font(.title2) - .fontWeight(.bold) - .accessibility(addTraits: [.isHeader]) - - Spacer() - - Button { - homeRouter - .route(to: \.library, ( - viewModel: .init( - parentID: library.id!, - filters: viewModel.recentFilterSet - ), - title: library.name ?? "" - )) - } label: { - HStack { - L10n.seeAll.text.font(.subheadline).fontWeight(.bold) - Image(systemName: "chevron.right").font(Font.subheadline.bold()) - } - } - } - .padding() - } - } - } - .padding(.bottom, 50) - } - .introspectScrollView { scrollView in - let control = UIRefreshControl() - - refreshHelper.refreshControl = control - refreshHelper.refreshAction = viewModel.refresh - - control.addTarget(refreshHelper, action: #selector(RefreshHelper.didRefresh), for: .valueChanged) - scrollView.refreshControl = control - } - } - } - - var body: some View { - innerBody - .navigationTitle(L10n.home) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - homeRouter.route(to: \.settings) - } label: { - Image(systemName: "gearshape.fill") - .accessibilityLabel(L10n.settings) - } - } - } - .onAppear { - refreshHelper.refreshStaleData() - } - } -} diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingCard.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingCard.swift new file mode 100644 index 00000000..f07d9c85 --- /dev/null +++ b/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingCard.swift @@ -0,0 +1,97 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct ContinueWatchingLandscapeButton: View { + + @EnvironmentObject + private var homeRouter: HomeCoordinator.Router + + let item: BaseItemDto + + var body: some View { + Button { + homeRouter.route(to: \.item, item) + } label: { + VStack(alignment: .leading) { + + ZStack { + Group { + if item.type == .episode { + ImageView([ + item.seriesImageSource(.thumb, maxWidth: 320), + item.seriesImageSource(.backdrop, maxWidth: 320), + ]) + .frame(width: 320, height: 180) + } else { + ImageView([ + item.imageSource(.thumb, maxWidth: 320), + item.imageSource(.backdrop, maxWidth: 320), + ]) + .frame(width: 320, height: 180) + } + } + .accessibilityIgnoresInvertColors() + + HStack { + VStack { + + Spacer() + + ZStack(alignment: .bottom) { + + LinearGradient( + colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 35) + + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? L10n.continue) + .font(.subheadline) + .padding(.bottom, 5) + .padding(.leading, 10) + .foregroundColor(.white) + + HStack { + Color.jellyfinPurple + .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) + + Spacer(minLength: 0) + } + } + } + } + } + } + .frame(width: 320, height: 180) + .mask(Rectangle().cornerRadius(10)) + .shadow(radius: 4, y: 2) + + VStack(alignment: .leading) { + Text("\(item.seriesName ?? item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + + if item.type == .episode { + Text(item.seasonEpisodeLocator ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + } +} diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingView.swift new file mode 100644 index 00000000..210a044e --- /dev/null +++ b/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingView.swift @@ -0,0 +1,37 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import NukeUI +import SwiftUI + +struct ContinueWatchingView: View { + + @EnvironmentObject + private var homeRouter: HomeCoordinator.Router + @ObservedObject + var viewModel: HomeViewModel + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 20) { + ForEach(viewModel.resumeItems, id: \.id) { item in + ContinueWatchingLandscapeButton(item: item) + .contextMenu { + Button(role: .destructive) { + viewModel.removeItemFromResume(item) + } label: { + Label(L10n.removeFromResume, systemImage: "minus.circle") + } + } + } + } + .padding(.horizontal) + } + } +} diff --git a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift new file mode 100644 index 00000000..5556ee10 --- /dev/null +++ b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift @@ -0,0 +1,39 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct LatestInLibraryView: View { + + @EnvironmentObject + private var homeRouter: HomeCoordinator.Router + @ObservedObject + var viewModel: LatestMediaViewModel + + var body: some View { + PortraitPosterHStack( + title: L10n.latestWithString(viewModel.library.displayName), + items: viewModel.items, + itemWidth: UIDevice.isIPad ? 130 : 110 + ) { + Button { + let libraryViewModel = LibraryViewModel(parentID: viewModel.library.id, filters: HomeViewModel.recentFilterSet) + homeRouter.route(to: \.library, (viewModel: libraryViewModel, title: viewModel.library.displayName)) + } label: { + HStack { + L10n.seeAll.text + Image(systemName: "chevron.right") + } + .font(.subheadline.bold()) + } + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } + } +} diff --git a/Swiftfin/Views/HomeView/HomeContentView.swift b/Swiftfin/Views/HomeView/HomeContentView.swift new file mode 100644 index 00000000..d9ed697e --- /dev/null +++ b/Swiftfin/Views/HomeView/HomeContentView.swift @@ -0,0 +1,57 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension HomeView { + + struct ContentView: View { + + @EnvironmentObject + private var homeRouter: HomeCoordinator.Router + @ObservedObject + var viewModel: HomeViewModel + + var body: some View { + RefreshableScrollView { + VStack(alignment: .leading, spacing: 20) { + if !viewModel.resumeItems.isEmpty { + ContinueWatchingView(viewModel: viewModel) + } + + if !viewModel.nextUpItems.isEmpty { + PortraitPosterHStack( + title: L10n.nextUp, + items: viewModel.nextUpItems, + itemWidth: UIDevice.isIPad ? 130 : 110 + ) { item in + homeRouter.route(to: \.item, item) + } + } + + if !viewModel.latestAddedItems.isEmpty { + PortraitPosterHStack( + title: L10n.recentlyAdded, + items: viewModel.latestAddedItems, + itemWidth: UIDevice.isIPad ? 130 : 110 + ) { item in + homeRouter.route(to: \.item, item) + } + } + + ForEach(viewModel.libraries, id: \.self) { library in + LatestInLibraryView(viewModel: .init(library: library)) + } + } + .padding(.bottom, 50) + } onRefresh: { + viewModel.refresh() + } + } + } +} diff --git a/Swiftfin/Views/HomeView/HomeErrorView.swift b/Swiftfin/Views/HomeView/HomeErrorView.swift new file mode 100644 index 00000000..e24f0f35 --- /dev/null +++ b/Swiftfin/Views/HomeView/HomeErrorView.swift @@ -0,0 +1,48 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension HomeView { + + struct ErrorView: View { + + @ObservedObject + var viewModel: HomeViewModel + + let errorMessage: ErrorMessage + + var body: some View { + VStack(spacing: 5) { + if viewModel.isLoading { + ProgressView() + .frame(width: 100, height: 100) + .scaleEffect(2) + } else { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 72)) + .foregroundColor(Color.red) + .frame(width: 100, height: 100) + } + + Text("\(errorMessage.code)") + + Text(errorMessage.message) + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) + + PrimaryButton(title: L10n.retry) { + viewModel.refresh() + } + .frame(maxWidth: 300) + .frame(height: 50) + } + .offset(y: -50) + } + } +} diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift new file mode 100644 index 00000000..42c6ea94 --- /dev/null +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -0,0 +1,41 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation +import SwiftUI + +struct HomeView: View { + + @EnvironmentObject + private var homeRouter: HomeCoordinator.Router + @ObservedObject + var viewModel: HomeViewModel + + var body: some View { + Group { + if let errorMessage = viewModel.errorMessage { + ErrorView(viewModel: viewModel, errorMessage: errorMessage) + } else if viewModel.isLoading { + ProgressView() + } else { + ContentView(viewModel: viewModel) + } + } + .navigationTitle(L10n.home) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + homeRouter.route(to: \.settings) + } label: { + Image(systemName: "gearshape.fill") + .accessibilityLabel(L10n.settings) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemOverviewView.swift b/Swiftfin/Views/ItemOverviewView.swift index b4a3d700..29f0f200 100644 --- a/Swiftfin/Views/ItemOverviewView.swift +++ b/Swiftfin/Views/ItemOverviewView.swift @@ -12,13 +12,13 @@ import SwiftUI struct ItemOverviewView: View { @EnvironmentObject - var itemOverviewRouter: ItemOverviewCoordinator.Router + private var itemOverviewRouter: ItemOverviewCoordinator.Router let item: BaseItemDto var body: some View { ScrollView(showsIndicators: false) { Text(item.overview ?? "") - .font(.footnote) + .font(.body) .padding() } .navigationBarTitle(L10n.overview, displayMode: .inline) diff --git a/Swiftfin/Views/ItemView/Components/AboutView.swift b/Swiftfin/Views/ItemView/Components/AboutView.swift new file mode 100644 index 00000000..7fa2192c --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AboutView.swift @@ -0,0 +1,81 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension ItemView { + + struct AboutView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading) { + L10n.about.text + .font(.title2) + .fontWeight(.bold) + .accessibility(addTraits: [.isHeader]) + .padding(.horizontal) + .if(UIDevice.isIPad) { view in + view.padding(.horizontal) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ImageView( + viewModel.item.type == .episode ? viewModel.item.seriesImageSource(.primary, maxWidth: 300) : viewModel + .item.imageSource(.primary, maxWidth: 300) + ) + .portraitPoster(width: 130) + .accessibilityIgnoresInvertColors() + + Button { + itemRouter.route(to: \.itemOverview, viewModel.item) + } label: { + ZStack { + + Color.secondarySystemFill + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 10) { + Text(viewModel.item.displayName) + .font(.title2) + .fontWeight(.semibold) + + Spacer() + + if let overview = viewModel.item.overview { + Text(overview) + .lineLimit(4) + .font(.footnote) + .foregroundColor(.secondary) + } else { + L10n.noOverviewAvailable.text + .font(.footnote) + .foregroundColor(.secondary) + } + } + .padding() + } + .frame(width: 330, height: 195) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal) + .if(UIDevice.isIPad) { view in + view.padding(.horizontal) + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift new file mode 100644 index 00000000..fbbd3854 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift @@ -0,0 +1,90 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct ActionButtonHStack: View { + + @ObservedObject + private var viewModel: ItemViewModel + private let equalSpacing: Bool + + init(viewModel: ItemViewModel, equalSpacing: Bool = true) { + self.viewModel = viewModel + self.equalSpacing = equalSpacing + } + + var body: some View { + HStack(alignment: .center, spacing: 15) { + Button { + UIDevice.impact(.light) + viewModel.toggleWatchState() + } label: { + if viewModel.isWatched { + Image(systemName: "checkmark.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle( + .primary, + Color.jellyfinPurple + ) + } else { + Image(systemName: "checkmark.circle") + .foregroundStyle(.white) + } + } + .buttonStyle(PlainButtonStyle()) + .if(equalSpacing) { view in + view.frame(maxWidth: .infinity) + } + + Button { + UIDevice.impact(.light) + viewModel.toggleFavoriteState() + } label: { + if viewModel.isFavorited { + Image(systemName: "heart.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(Color.red) + } else { + Image(systemName: "heart") + .foregroundStyle(.white) + } + } + .buttonStyle(PlainButtonStyle()) + .if(equalSpacing) { view in + view.frame(maxWidth: .infinity) + } + + if viewModel.videoPlayerViewModels.count > 1 { + Menu { + ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in + Button { + viewModel.selectedVideoPlayerViewModel = viewModelOption + } label: { + if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName { + Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(viewModelOption.versionName ?? L10n.noTitle) + } + } + } + } label: { + HStack(spacing: 5) { + Image(systemName: "list.dash") + } + } + .if(equalSpacing) { view in + view.frame(maxWidth: .infinity) + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift new file mode 100644 index 00000000..a2c76cb0 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift @@ -0,0 +1,49 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct AttributesHStack: View { + + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + HStack { + if let officialRating = viewModel.item.officialRating { + AttributeOutlineView(text: officialRating) + } + + if let selectedPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + if selectedPlayerViewModel.item.isHD ?? false { + AttributeFillView(text: "HD") + } + + if (selectedPlayerViewModel.videoStream.width ?? 0) > 3800 { + AttributeFillView(text: "4K") + } + + if selectedPlayerViewModel.audioStreams.contains(where: { $0.channelLayout == "5.1" }) { + AttributeFillView(text: "5.1") + } + + if selectedPlayerViewModel.audioStreams.contains(where: { $0.channelLayout == "7.1" }) { + AttributeFillView(text: "7.1") + } + + if !selectedPlayerViewModel.subtitleStreams.isEmpty { + AttributeOutlineView(text: "CC") + } + } + } + .foregroundColor(Color(UIColor.darkGray)) + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift new file mode 100644 index 00000000..b6775c41 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift @@ -0,0 +1,68 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct EpisodeCard: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ScaledMetric + private var staticOverviewHeight: CGFloat = 50 + @Environment(\.colorScheme) + private var colorScheme + + let episode: BaseItemDto + + var body: some View { + Button { + if episode != .placeHolder && episode != .noResults { + itemRouter.route(to: \.item, episode) + } + } label: { + VStack(alignment: .leading) { + ImageView(episode.imageSource(.primary, maxWidth: 200)) + .frame(width: 200, height: 112) + .cornerRadius(10) + .accessibilityIgnoresInvertColors() + + VStack(alignment: .leading) { + Text(episode.episodeLocator ?? L10n.unknown) + .font(.footnote) + .foregroundColor(.secondary) + + Text(episode.displayName) + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + + ZStack(alignment: .topLeading) { + Color.clear + .frame(height: staticOverviewHeight) + + if episode.unaired { + Text(episode.airDateLabel ?? L10n.noOverviewAvailable) + } else { + Text(episode.overview ?? L10n.noOverviewAvailable) + } + } + .font(.caption.weight(.light)) + .foregroundColor(.secondary) + .lineLimit(4) + .multilineTextAlignment(.leading) + } + } + .frame(width: 200) + } + .buttonStyle(PlainButtonStyle()) + .if(colorScheme == .light) { view in + view.shadow(radius: 4, y: 2) + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift b/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift new file mode 100644 index 00000000..aa3ef5fb --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift @@ -0,0 +1,85 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct SeriesEpisodesView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: RowManager + + @ViewBuilder + private var headerView: some View { + HStack { + Menu { + ForEach(viewModel.sortedSeasons) { season in + Button { + viewModel.select(season: season) + } label: { + if season.id == viewModel.selectedSeason?.id { + Label(season.name ?? L10n.unknown, systemImage: "checkmark") + } else { + Text(season.name ?? L10n.unknown) + } + } + } + } label: { + HStack(spacing: 5) { + Group { + Text(viewModel.selectedSeason?.name ?? L10n.unknown) + .fixedSize() + Image(systemName: "chevron.down") + } + .font(.title3.weight(.semibold)) + } + } + + Spacer() + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + + headerView + .padding(.horizontal) + .if(UIDevice.isIPad) { view in + view.padding(.horizontal) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 15) { + if viewModel.isLoading { + ForEach(0 ..< 5) { _ in + EpisodeCard(episode: .placeHolder) + .redacted(reason: .placeholder) + } + } else if let selectedSeason = viewModel.selectedSeason { + if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { + if seasonEpisodes.isEmpty { + EpisodeCard(episode: .noResults) + } else { + ForEach(seasonEpisodes) { episode in + EpisodeCard(episode: episode) + .id(episode.id) + } + } + } + } + } + .padding(.horizontal) + .if(UIDevice.isIPad) { view in + view.padding(.horizontal) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/ListDetailsView.swift b/Swiftfin/Views/ItemView/Components/ListDetailsView.swift new file mode 100644 index 00000000..a0905082 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/ListDetailsView.swift @@ -0,0 +1,42 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct ListDetailsView: View { + + let title: String + let items: [BaseItemDto.ItemDetail] + + var body: some View { + VStack(alignment: .leading) { + + VStack(alignment: .leading, spacing: 20) { + Text(title) + .font(.title3) + .fontWeight(.bold) + .accessibility(addTraits: [.isHeader]) + + ForEach(items, id: \.self.title) { item in + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.subheadline) + Text(item.content) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + .accessibilityElement(children: .combine) + } + } + .padding(.bottom, 20) + } + } +} diff --git a/Swiftfin/Views/ItemView/Components/PlayButton.swift b/Swiftfin/Views/ItemView/Components/PlayButton.swift new file mode 100644 index 00000000..90f801d5 --- /dev/null +++ b/Swiftfin/Views/ItemView/Components/PlayButton.swift @@ -0,0 +1,59 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct PlayButton: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + Button { + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + } else { + LogManager.log.error("Attempted to play item but no playback information available") + } + } label: { + ZStack { + Rectangle() + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .cornerRadius(10) + + HStack { + Image(systemName: "play.fill") + .font(.system(size: 20)) + Text(viewModel.playButtonText()) + .font(.callout) + .fontWeight(.semibold) + } + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + } + } + .contextMenu { + if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { + Button { + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) + itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + } else { + LogManager.log.error("Attempted to play item but no playback information available") + } + } label: { + Label(L10n.playFromBeginning, systemImage: "gobackward") + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index 35ab3db0..9eb30e57 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -9,64 +9,44 @@ import Introspect import JellyfinAPI import SwiftUI +import WidgetKit -// Intermediary view for ItemView to set navigation bar settings -struct ItemNavigationView: View { - private let item: BaseItemDto +struct ItemView: View { - init(item: BaseItemDto) { - self.item = item - } - - var body: some View { - ItemView(item: item) - .navigationBarTitle(item.name ?? "", displayMode: .inline) - .introspectNavigationController { navigationController in - let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear] - navigationController.navigationBar.titleTextAttributes = textAttributes - } - } -} - -private struct ItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - - @State - private var orientation: UIDeviceOrientation = .unknown - @Environment(\.horizontalSizeClass) - private var hSizeClass - @Environment(\.verticalSizeClass) - private var vSizeClass - - private let viewModel: ItemViewModel - - init(item: BaseItemDto) { - switch item.itemType { - case .movie: - self.viewModel = MovieItemViewModel(item: item) - case .season: - self.viewModel = SeasonItemViewModel(item: item) - case .episode: - self.viewModel = EpisodeItemViewModel(item: item) - case .series: - self.viewModel = SeriesItemViewModel(item: item) - case .boxset, .folder: - self.viewModel = CollectionItemViewModel(item: item) - default: - self.viewModel = ItemViewModel(item: item) - } - } + let item: BaseItemDto var body: some View { Group { - if hSizeClass == .compact && vSizeClass == .regular { - ItemPortraitMainView() - .environmentObject(viewModel) - } else { - ItemLandscapeMainView() - .environmentObject(viewModel) + switch item.type { + case .movie: + if UIDevice.isIPad { + iPadOSMovieItemView(viewModel: .init(item: item)) + } else { + MovieItemView(viewModel: .init(item: item)) + } + case .series: + if UIDevice.isIPad { + iPadOSSeriesItemView(viewModel: .init(item: item)) + } else { + SeriesItemView(viewModel: .init(item: item)) + } + case .episode: + if UIDevice.isIPad { + iPadOSEpisodeItemView(viewModel: .init(item: item)) + } else { + EpisodeItemView(viewModel: .init(item: item)) + } + case .boxSet: + if UIDevice.isIPad { + iPadOSCollectionItemView(viewModel: .init(item: item)) + } else { + CollectionItemView(viewModel: .init(item: item)) + } + default: + Text(L10n.notImplementedYetWithType(item.type ?? "--")) } } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(item.displayName) } } diff --git a/Swiftfin/Views/ItemView/ItemViewBody.swift b/Swiftfin/Views/ItemView/ItemViewBody.swift deleted file mode 100644 index 1fd32492..00000000 --- a/Swiftfin/Views/ItemView/ItemViewBody.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct ItemViewBody: View { - - @Environment(\.horizontalSizeClass) - private var hSizeClass - @Environment(\.verticalSizeClass) - private var vSizeClass - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @EnvironmentObject - private var viewModel: ItemViewModel - @Default(.showCastAndCrew) - var showCastAndCrew - - var body: some View { - VStack(alignment: .leading) { - // MARK: Overview - - if let itemOverview = viewModel.item.overview { - if hSizeClass == .compact && vSizeClass == .regular { - TruncatedTextView( - itemOverview, - lineLimit: 5, - font: UIFont.preferredFont(forTextStyle: .footnote) - ) { - itemRouter.route(to: \.itemOverview, viewModel.item) - } - .padding(.horizontal) - .padding(.top) - } else { - Text(itemOverview) - .font(.footnote) - .padding() - } - } else { - L10n.noOverviewAvailable.text - .font(.footnote) - .padding() - } - - // MARK: Seasons - - if let seriesViewModel = viewModel as? SeriesItemViewModel { - PortraitImageHStackView( - items: seriesViewModel.seasons, - topBarView: { - L10n.seasons.text - .fontWeight(.semibold) - .padding() - .accessibility(addTraits: [.isHeader]) - }, - selectedAction: { season in - itemRouter.route(to: \.item, season) - } - ) - } - - // MARK: Genres - - if let genres = viewModel.item.genreItems, !genres.isEmpty { - PillHStackView( - title: L10n.genres, - items: genres, - selectedAction: { genre in - itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) - } - ) - .padding(.bottom) - } - - // MARK: Studios - - if let studios = viewModel.item.studios { - PillHStackView( - title: L10n.studios, - items: studios - ) { studio in - itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) - } - .padding(.bottom) - } - - // MARK: Episodes - - if let episodeViewModel = viewModel as? EpisodeItemViewModel { - EpisodesRowView(viewModel: episodeViewModel, onlyCurrentSeason: false) - } else if let seasonViewModel = viewModel as? SeasonItemViewModel { - EpisodesRowView(viewModel: seasonViewModel, onlyCurrentSeason: true) - } - - // MARK: Series - - if let episodeViewModel = viewModel as? EpisodeItemViewModel { - if let seriesItem = episodeViewModel.series { - let a = [seriesItem] - PortraitImageHStackView(items: a) { - L10n.series.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - } selectedAction: { seriesItem in - itemRouter.route(to: \.item, seriesItem) - } - } - } - - // MARK: Collection Items - - if let collectionViewModel = viewModel as? CollectionItemViewModel { - PortraitImageHStackView(items: collectionViewModel.collectionItems) { - L10n.items.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - .accessibility(addTraits: [.isHeader]) - } selectedAction: { collectionItem in - itemRouter.route(to: \.item, collectionItem) - } - } - - // MARK: Cast & Crew - - if showCastAndCrew { - if let castAndCrew = viewModel.item.people, !castAndCrew.isEmpty { - PortraitImageHStackView( - items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, - topBarView: { - L10n.castAndCrew.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - .accessibility(addTraits: [.isHeader]) - }, - selectedAction: { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - } - ) - } - } - - // MARK: Recommended - - if !viewModel.similarItems.isEmpty { - PortraitImageHStackView( - items: viewModel.similarItems, - topBarView: { - L10n.recommended.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - .accessibility(addTraits: [.isHeader]) - }, - selectedAction: { item in - itemRouter.route(to: \.item, item) - } - ) - } - - // MARK: Details - - switch viewModel.item.itemType { - case .movie, .episode: - ItemViewDetailsView(viewModel: viewModel) - .padding() - default: - EmptyView() - .frame(height: 50) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/ItemViewDetailsView.swift b/Swiftfin/Views/ItemView/ItemViewDetailsView.swift deleted file mode 100644 index 73ee4975..00000000 --- a/Swiftfin/Views/ItemView/ItemViewDetailsView.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct ItemViewDetailsView: View { - - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - VStack(alignment: .leading) { - - if !viewModel.informationItems.isEmpty { - VStack(alignment: .leading, spacing: 20) { - L10n.information.text - .font(.title3) - .fontWeight(.bold) - .accessibility(addTraits: [.isHeader]) - - ForEach(viewModel.informationItems, id: \.self.title) { informationItem in - VStack(alignment: .leading, spacing: 2) { - Text(informationItem.title) - .font(.subheadline) - Text(informationItem.content) - .font(.subheadline) - .foregroundColor(Color.secondary) - } - .accessibilityElement(children: .combine) - } - } - .padding(.bottom, 20) - } - - VStack(alignment: .leading, spacing: 20) { - L10n.media.text - .font(.title3) - .fontWeight(.bold) - .accessibility(addTraits: [.isHeader]) - - VStack(alignment: .leading, spacing: 2) { - L10n.file.text - .font(.subheadline) - Text(viewModel.selectedVideoPlayerViewModel?.filename ?? "--") - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .font(.subheadline) - .foregroundColor(Color.secondary) - } - .accessibilityElement(children: .combine) - - VStack(alignment: .leading, spacing: 2) { - L10n.containers.text - .font(.subheadline) - Text(viewModel.selectedVideoPlayerViewModel?.container ?? "--") - .font(.subheadline) - .foregroundColor(Color.secondary) - } - .accessibilityElement(children: .combine) - - ForEach(viewModel.selectedVideoPlayerViewModel?.mediaItems ?? [], id: \.self.title) { mediaItem in - VStack(alignment: .leading, spacing: 2) { - Text(mediaItem.title) - .font(.subheadline) - Text(mediaItem.content) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .font(.subheadline) - .foregroundColor(Color.secondary) - } - .accessibilityElement(children: .combine) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift deleted file mode 100644 index 5865135a..00000000 --- a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Stinsen -import SwiftUI - -struct ItemLandscapeMainView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @EnvironmentObject - private var viewModel: ItemViewModel - @State - private var playButtonText: String = "" - - // MARK: innerBody - - private var innerBody: some View { - HStack { - // MARK: Sidebar Image - - VStack { - ImageView( - viewModel.item.portraitHeaderViewURL(maxWidth: 130), - blurHash: viewModel.item.getPrimaryImageBlurHash() - ) - .frame(width: 130, height: 195) - .cornerRadius(10) - .accessibilityIgnoresInvertColors() - - Spacer().frame(height: 15) - - // MARK: Play - - Button { - self.itemRouter.route(to: \.videoPlayer, viewModel.selectedVideoPlayerViewModel!) - } label: { - HStack { - Image(systemName: "play.fill") - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.system(size: 20)) - Text(viewModel.playButtonText()) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.callout) - .fontWeight(.semibold) - } - .frame(width: 130, height: 40) - .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) - .cornerRadius(10) - } - .disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil) - .contextMenu { - if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - Label(L10n.playFromBeginning, systemImage: "gobackward") - } - } - } - - Spacer() - } - - ScrollView { - VStack(alignment: .leading) { - // MARK: ItemLandscapeTopBarView - - ItemLandscapeTopBarView() - .environmentObject(viewModel) - - // MARK: ItemViewBody - - ItemViewBody() - .environmentObject(viewModel) - } - } - } - .onAppear { - playButtonText = viewModel.playButtonText() - } - } - - // MARK: body - - var body: some View { - VStack { - ZStack { - // MARK: Backdrop - - ImageView( - viewModel.item.getBackdropImage(maxWidth: 200), - blurHash: viewModel.item.getBackdropImageBlurHash() - ) - .opacity(0.3) - .edgesIgnoringSafeArea(.all) - .blur(radius: 8) - .layoutPriority(-1) - .accessibilityIgnoresInvertColors() - - // iPadOS is making the view go all the way to the edge. - // We have to accomodate this here - if UIDevice.current.userInterfaceIdiom == .pad { - innerBody.padding(.horizontal, 25) - } else { - innerBody - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift deleted file mode 100644 index 79b9e827..00000000 --- a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct ItemLandscapeTopBarView: View { - - @EnvironmentObject - private var viewModel: ItemViewModel - - var body: some View { - HStack { - VStack(alignment: .leading) { - - // MARK: Name - - Text(viewModel.getItemDisplayName()) - .font(.title) - .fontWeight(.semibold) - .foregroundColor(.primary) - .padding(.leading, 16) - .padding(.bottom, 10) - .accessibility(addTraits: [.isHeader]) - - // MARK: Details - - HStack { - - if viewModel.item.unaired { - if let premiereDateLabel = viewModel.item.airDateLabel { - Text(premiereDateLabel) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .padding(.leading, 16) - } - - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear ?? 0)) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - } - - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1) - ) - } - - Spacer() - - if viewModel.item.itemType.showDetails { - // MARK: Favorite - - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - - // MARK: Watched - - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - } - .padding(.leading) - - if viewModel.videoPlayerViewModels.count > 1 { - Menu { - ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in - Button { - viewModel.selectedVideoPlayerViewModel = viewModelOption - } label: { - if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName { - Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(viewModelOption.versionName ?? L10n.noTitle) - } - } - } - } label: { - HStack(spacing: 5) { - Text(viewModel.selectedVideoPlayerViewModel?.versionName ?? L10n.noTitle) - .fontWeight(.semibold) - .fixedSize() - Image(systemName: "chevron.down") - } - } - .padding(.leading) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift deleted file mode 100644 index 865e6bff..00000000 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct PortraitHeaderOverlayView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @EnvironmentObject - private var viewModel: ItemViewModel - @State - private var playButtonText: String = "" - - var body: some View { - VStack(alignment: .leading) { - HStack(alignment: .bottom, spacing: 12) { - - // MARK: Portrait Image - - ImageView( - viewModel.item.portraitHeaderViewURL(maxWidth: 130), - blurHash: viewModel.item.getPrimaryImageBlurHash() - ) - .portraitPoster(width: 130) - .accessibilityIgnoresInvertColors() - - VStack(alignment: .leading, spacing: 1) { - Spacer() - - // MARK: Name - - Text(viewModel.getItemDisplayName()) - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 10) - - // MARK: Details - - HStack { - if viewModel.item.unaired { - if let premiereDateLabel = viewModel.item.airDateLabel { - Text(premiereDateLabel) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - - if viewModel.shouldDisplayRuntime() { - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - - if let productionYear = viewModel.item.productionYear { - Text(String(productionYear)) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - - if let officialRating = viewModel.item.officialRating { - Text(officialRating) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1) - ) - } - } - - if viewModel.videoPlayerViewModels.count > 1 { - Menu { - ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in - Button { - viewModel.selectedVideoPlayerViewModel = viewModelOption - } label: { - if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName { - Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(viewModelOption.versionName ?? L10n.noTitle) - } - } - } - } label: { - HStack(spacing: 5) { - Text(viewModel.selectedVideoPlayerViewModel?.versionName ?? L10n.noTitle) - .fontWeight(.semibold) - .fixedSize() - Image(systemName: "chevron.down") - } - } - } - } - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) - } - - HStack { - - // MARK: Play - - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - HStack { - Image(systemName: "play.fill") - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.system(size: 20)) - Text(playButtonText) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.callout) - .fontWeight(.semibold) - } - .frame(width: 130, height: 40) - .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) - .cornerRadius(10) - } - .disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil) - .contextMenu { - if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - Label(L10n.playFromBeginning, systemImage: "gobackward") - } - } - } - - Spacer() - - if viewModel.item.itemType.showDetails { - // MARK: Favorite - - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill") - .foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart") - .foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - - // MARK: Watched - - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color.jellyfinPurple) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle") - .foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - }.padding(.top, 8) - } - .onAppear { - playButtonText = viewModel.playButtonText() - } - .padding(.horizontal) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) - } -} diff --git a/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift deleted file mode 100644 index dee7b781..00000000 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct ItemPortraitMainView: View { - - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @EnvironmentObject - private var viewModel: ItemViewModel - - // MARK: portraitHeaderView - - var portraitHeaderView: some View { - ImageView( - viewModel.item.getBackdropImage(maxWidth: Int(UIScreen.main.bounds.width)), - blurHash: viewModel.item.getBackdropImageBlurHash() - ) - .opacity(0.4) - .blur(radius: 2.0) - .accessibilityIgnoresInvertColors() - } - - // MARK: portraitStaticOverlayView - - var portraitStaticOverlayView: some View { - PortraitHeaderOverlayView() - .environmentObject(viewModel) - } - - // MARK: body - - var body: some View { - VStack(alignment: .leading) { - // MARK: ParallaxScrollView - - ParallaxHeaderScrollView( - header: portraitHeaderView, - staticOverlayView: portraitStaticOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIScreen.main.bounds.width * 0.5625 - ) { - VStack { - Spacer() - .frame(height: 70) - - ItemViewBody() - .environmentObject(viewModel) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift new file mode 100644 index 00000000..4dcbe4f4 --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift @@ -0,0 +1,62 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension CollectionItemView { + + struct ContentView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: CollectionItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + + // MARK: Genres + + if let genres = viewModel.item.genreItems, !genres.isEmpty { + PillHStack( + title: L10n.genres, + items: genres + ) { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } + + Divider() + } + + // MARK: Studios + + if let studios = viewModel.item.studios { + PillHStack( + title: L10n.studios, + items: studios + ) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + + Divider() + } + + // MARK: Items + + if !viewModel.collectionItems.isEmpty { + PortraitPosterHStack( + title: L10n.items, + items: viewModel.collectionItems + ) { item in + itemRouter.route(to: \.item, item) + } + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift new file mode 100644 index 00000000..add70278 --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift @@ -0,0 +1,35 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +struct CollectionItemView: View { + + @ObservedObject + var viewModel: CollectionItemViewModel + @Default(.itemViewType) + private var itemViewType + + var body: some View { + switch itemViewType { + case .compactPoster: + ItemView.CompactPosterScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + case .compactLogo: + ItemView.CompactLogoScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + case .cinematic: + ItemView.CinematicScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift new file mode 100644 index 00000000..427c42e3 --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift @@ -0,0 +1,149 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension EpisodeItemView { + + struct ContentView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodeItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .center) { + ImageView(viewModel.item.imageSource(.primary, maxWidth: 600)) + .frame(maxHeight: 300) + .aspectRatio(1.77, contentMode: .fill) + .cornerRadius(10) + .padding(.horizontal) + + ShelfView(viewModel: viewModel) + } + + // MARK: Overview + + if let itemOverview = viewModel.item.overview { + TruncatedTextView(text: itemOverview) { + itemRouter.route(to: \.itemOverview, viewModel.item) + } + .font(.footnote) + .lineLimit(5) + .padding(.horizontal) + } + + // MARK: Genres + + if let genres = viewModel.item.genreItems, !genres.isEmpty { + PillHStack( + title: L10n.genres, + items: genres, + selectedAction: { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } + ) + + Divider() + } + + if let studios = viewModel.item.studios, !studios.isEmpty { + PillHStack( + title: L10n.studios, + items: studios + ) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + + Divider() + } + + if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), + !castAndCrew.isEmpty + { + PortraitPosterHStack( + title: L10n.castAndCrew, + items: castAndCrew + ) { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } + + Divider() + } + + // MARK: Details + + if let informationItems = viewModel.item.createInformationItems(), !informationItems.isEmpty { + ListDetailsView(title: L10n.information, items: informationItems) + .padding(.horizontal) + } + } + } + } +} + +extension EpisodeItemView.ContentView { + + struct ShelfView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodeItemViewModel + + var body: some View { + VStack(alignment: .center, spacing: 10) { + Text(viewModel.item.seriesName ?? "--") + .font(.headline) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.horizontal) + .foregroundColor(.secondary) + + Text(viewModel.item.displayName) + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .lineLimit(2) + .padding(.horizontal) + + DotHStack { + if let episodeLocation = viewModel.item.episodeLocator { + Text(episodeLocation) + } + + if let productionYear = viewModel.item.premiereDateYear { + Text(productionYear) + } + + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal) + + ItemView.AttributesHStack(viewModel: viewModel) + + ItemView.PlayButton(viewModel: viewModel) + .frame(maxWidth: 300) + .frame(height: 50) + + ItemView.ActionButtonHStack(viewModel: viewModel) + .font(.title) + .frame(maxWidth: 300) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift new file mode 100644 index 00000000..f6101c9b --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift @@ -0,0 +1,32 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct EpisodeItemView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @State + private var scrollViewOffset: CGFloat = 0 + @ObservedObject + var viewModel: EpisodeItemViewModel + + var body: some View { + ScrollView(showsIndicators: false) { + ContentView(viewModel: viewModel) + } + .scrollViewOffset($scrollViewOffset) + .navBarOffset( + $scrollViewOffset, + start: 0, + end: 10 + ) + } +} diff --git a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift new file mode 100644 index 00000000..d7dfb745 --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift @@ -0,0 +1,88 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension MovieItemView { + + struct ContentView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: MovieItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + // MARK: Genres + + if let genres = viewModel.item.genreItems, !genres.isEmpty { + PillHStack( + title: L10n.genres, + items: genres + ) { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } + + Divider() + } + + // MARK: Studios + + if let studios = viewModel.item.studios, !studios.isEmpty { + PillHStack( + title: L10n.studios, + items: studios + ) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + + Divider() + } + + // MARK: Cast and Crew + + if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), + !castAndCrew.isEmpty + { + PortraitPosterHStack( + title: L10n.castAndCrew, + items: castAndCrew + ) { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } + + Divider() + } + + // MARK: Similar + + if !viewModel.similarItems.isEmpty { + PortraitPosterHStack( + title: L10n.recommended, + items: viewModel.similarItems + ) { item in + itemRouter.route(to: \.item, item) + } + + Divider() + } + + ItemView.AboutView(viewModel: viewModel) + + if let informationItems = viewModel.item.createInformationItems(), !informationItems.isEmpty { + ListDetailsView(title: L10n.information, items: informationItems) + .padding(.horizontal) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift new file mode 100644 index 00000000..3cd60a6b --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +struct MovieItemView: View { + + @ObservedObject + var viewModel: MovieItemViewModel + @Default(.itemViewType) + private var itemViewType + + var body: some View { + switch itemViewType { + case .compactPoster: + ItemView.CompactPosterScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + case .compactLogo: + ItemView.CompactLogoScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + case .cinematic: + ItemView.CinematicScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift new file mode 100644 index 00000000..85dd9336 --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift @@ -0,0 +1,182 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct CinematicScrollView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @State + private var scrollViewOffset: CGFloat = 0 + @State + private var blurHashBottomEdgeColor: Color = .secondarySystemFill + @ObservedObject + var viewModel: ItemViewModel + + let content: () -> Content + + private var topOpacity: CGFloat { + let start = UIScreen.main.bounds.height * 0.5 + let end = UIScreen.main.bounds.height * 0.65 + let diff = end - start + let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) + return opacity + } + + @ViewBuilder + private var headerView: some View { + ImageView(viewModel.item.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)) + .frame(height: UIScreen.main.bounds.height * 0.6) + .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) + .onAppear { + if let headerBlurHash = viewModel.item.blurHash(.backdrop) { + let bottomRGB = BlurHash(string: headerBlurHash)!.averageLinearRGB + blurHashBottomEdgeColor = Color( + red: Double(bottomRGB.0), + green: Double(bottomRGB.1), + blue: Double(bottomRGB.2) + ) + } + } + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + + VStack(spacing: 0) { + Spacer() + + OverlayView(viewModel: viewModel) + .padding(.horizontal) + .padding(.bottom) + .background { + BlurView(style: .systemThinMaterialDark) + .mask { + LinearGradient( + stops: [ + .init(color: .white.opacity(0), location: 0), + .init(color: .white, location: 0.3), + .init(color: .white, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .overlay { + Color.systemBackground + .opacity(topOpacity) + } + } + .frame(height: UIScreen.main.bounds.height * 0.8) + + content() + .padding(.vertical) + .background(Color.systemBackground) + } + } + .edgesIgnoringSafeArea(.top) + .scrollViewOffset($scrollViewOffset) + .navBarOffset( + $scrollViewOffset, + start: UIScreen.main.bounds.height * 0.66, + end: UIScreen.main.bounds.height * 0.66 + 50 + ) + .backgroundParallaxHeader( + $scrollViewOffset, + height: UIScreen.main.bounds.height * 0.6, + multiplier: 0.3 + ) { + headerView + } + } + } +} + +extension ItemView.CinematicScrollView { + + struct OverlayView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + VStack(alignment: .center, spacing: 10) { + ImageView( + viewModel.item.imageURL(.logo, maxWidth: UIScreen.main.bounds.width), + resizingMode: .aspectFit + ) { + Text(viewModel.item.displayName) + .font(.largeTitle) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .foregroundColor(.white) + } + .frame(height: 100) + .frame(maxWidth: .infinity) + + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + .padding(.horizontal) + + ItemView.PlayButton(viewModel: viewModel) + .frame(maxWidth: 300) + .frame(height: 50) + + ItemView.ActionButtonHStack(viewModel: viewModel) + .font(.title) + .frame(maxWidth: 300) + } + .frame(maxWidth: .infinity) + + if let firstTagline = viewModel.item.taglines?.first { + Text(firstTagline) + .font(.body) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.white) + } + + if let itemOverview = viewModel.item.overview { + TruncatedTextView(text: itemOverview) { + itemRouter.route(to: \.itemOverview, viewModel.item) + } + .font(.footnote) + .lineLimit(4) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + } + + ItemView.AttributesHStack(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift new file mode 100644 index 00000000..e62a003e --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift @@ -0,0 +1,189 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct CompactLogoScrollView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @State + private var scrollViewOffset: CGFloat = 0 + @State + private var blurHashBottomEdgeColor: Color = .secondarySystemFill + @ObservedObject + var viewModel: ItemViewModel + + let content: () -> Content + + private var topOpacity: CGFloat { + let start = UIScreen.main.bounds.height * 0.25 + let end = UIScreen.main.bounds.height * 0.44 + let diff = end - start + let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) + return opacity + } + + @ViewBuilder + private var headerView: some View { + ImageView(viewModel.item.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)) + .frame(height: UIScreen.main.bounds.height * 0.35) + .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) + .onAppear { + if let backdropBlurHash = viewModel.item.blurHash(.backdrop) { + let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB + blurHashBottomEdgeColor = Color( + red: Double(bottomRGB.0), + green: Double(bottomRGB.1), + blue: Double(bottomRGB.2) + ) + } + } + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + + Color.clear + .frame(height: UIScreen.main.bounds.height * 0.25) + + OverlayView(scrollViewOffset: $scrollViewOffset, viewModel: viewModel) + .padding(.horizontal) + .padding(.bottom) + .background { + BlurView(style: .systemThinMaterialDark) + .mask { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .white, location: 0.15), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .overlay { + Color.systemBackground + .opacity(topOpacity) + } + + VStack(alignment: .leading, spacing: 10) { + if let firstTagline = viewModel.item.taglines?.first { + Text(firstTagline) + .font(.body) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + + if let itemOverview = viewModel.item.overview { + TruncatedTextView(text: itemOverview) { + itemRouter.route(to: \.itemOverview, viewModel.item) + } + .font(.footnote) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal) + .padding(.top) + .frame(maxWidth: .infinity) + .background(Color.systemBackground) + + content() + .padding(.vertical) + .background(Color.systemBackground) + } + } + .edgesIgnoringSafeArea(.top) + .scrollViewOffset($scrollViewOffset) + .navBarOffset( + $scrollViewOffset, + start: UIScreen.main.bounds.height * 0.43, + end: UIScreen.main.bounds.height * 0.43 + 50 + ) + .backgroundParallaxHeader( + $scrollViewOffset, + height: UIScreen.main.bounds.height * 0.35, + multiplier: 0.3 + ) { + headerView + } + } + } +} + +extension ItemView.CompactLogoScrollView { + + struct OverlayView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @Binding + var scrollViewOffset: CGFloat + @ObservedObject + var viewModel: ItemViewModel + + private var topOpacity: CGFloat { + let start = UIScreen.main.bounds.height * 0.25 + let end = UIScreen.main.bounds.height * 0.44 + let diff = end - start + let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) + return 1 - opacity + } + + var body: some View { + VStack(alignment: .center, spacing: 10) { + ImageView( + viewModel.item.imageURL(.logo, maxWidth: UIScreen.main.bounds.width), + resizingMode: .aspectFit + ) { + Text(viewModel.item.displayName) + .font(.largeTitle) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .foregroundColor(.white) + } + .frame(height: 100) + .frame(maxWidth: .infinity) + + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + .padding(.horizontal) + + ItemView.AttributesHStack(viewModel: viewModel) + + ItemView.PlayButton(viewModel: viewModel) + .frame(maxWidth: 300) + .frame(height: 50) + + ItemView.ActionButtonHStack(viewModel: viewModel) + .font(.title) + .frame(maxWidth: 300) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift new file mode 100644 index 00000000..40f55ff9 --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift @@ -0,0 +1,203 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct CompactPosterScrollView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @State + private var scrollViewOffset: CGFloat = 0 + @State + private var blurHashBottomEdgeColor: Color = .secondarySystemFill + @ObservedObject + var viewModel: ItemViewModel + + let content: () -> Content + + private var topOpacity: CGFloat { + let start = UIScreen.main.bounds.height * 0.20 + let end = UIScreen.main.bounds.height * 0.4 + let diff = end - start + let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) + return opacity + } + + @ViewBuilder + private var headerView: some View { + ImageView(viewModel.item.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)) + .frame(height: UIScreen.main.bounds.height * 0.35) + .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + Color.clear + .frame(height: UIScreen.main.bounds.height * 0.15) + + OverlayView(scrollViewOffset: $scrollViewOffset, viewModel: viewModel) + .padding(.horizontal) + .padding(.bottom) + .background { + BlurView(style: .systemThinMaterialDark) + .mask { + LinearGradient( + stops: [ + .init(color: .white.opacity(0), location: 0.2), + .init(color: .white.opacity(0.5), location: 0.3), + .init(color: .white, location: 0.55), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .overlay { + Color.systemBackground + .opacity(topOpacity) + } + + VStack(alignment: .leading, spacing: 10) { + if let firstTagline = viewModel.item.taglines?.first { + Text(firstTagline) + .font(.body) + .fontWeight(.semibold) + .multilineTextAlignment(.leading) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + + if let itemOverview = viewModel.item.overview { + TruncatedTextView(text: itemOverview) { + itemRouter.route(to: \.itemOverview, viewModel.item) + } + .font(.footnote) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal) + .padding(.top) + .frame(maxWidth: .infinity) + .background(Color.systemBackground) + .foregroundColor(.white) + + content() + .padding(.vertical) + .background(Color.systemBackground) + } + } + .edgesIgnoringSafeArea(.top) + .scrollViewOffset($scrollViewOffset) + .navBarOffset( + $scrollViewOffset, + start: UIScreen.main.bounds.height * 0.28, + end: UIScreen.main.bounds.height * 0.28 + 50 + ) + .backgroundParallaxHeader( + $scrollViewOffset, + height: UIScreen.main.bounds.height * 0.35, + multiplier: 0.8 + ) { + headerView + } + .onAppear { + if let backdropBlurHash = viewModel.item.blurHash(.backdrop) { + let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB + blurHashBottomEdgeColor = Color( + red: Double(bottomRGB.0), + green: Double(bottomRGB.1), + blue: Double(bottomRGB.2) + ) + } + } + } + } +} + +extension ItemView.CompactPosterScrollView { + + struct OverlayView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @Binding + var scrollViewOffset: CGFloat + @ObservedObject + var viewModel: ItemViewModel + + @ViewBuilder + private var rightShelfView: some View { + VStack(alignment: .leading) { + + // MARK: Name + + Text(viewModel.item.displayName) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.white) + + // MARK: Details + + DotHStack { + if viewModel.item.unaired { + if let premiereDateLabel = viewModel.item.airDateLabel { + Text(premiereDateLabel) + } + } else { + if let productionYear = viewModel.item.productionYear { + Text(String(productionYear)) + } + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + Text(runtime) + } + } + .lineLimit(1) + .font(.subheadline.weight(.medium)) + .foregroundColor(Color(UIColor.lightGray)) + + ItemView.AttributesHStack(viewModel: viewModel) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .bottom, spacing: 12) { + + // MARK: Portrait Image + + ImageView(viewModel.item.imageSource(.primary, maxWidth: 130)) + .portraitPoster(width: 130) + .accessibilityIgnoresInvertColors() + + rightShelfView + .padding(.bottom) + } + + // MARK: Play + + HStack(alignment: .center) { + + ItemView.PlayButton(viewModel: viewModel) + .frame(width: 130, height: 40) + + Spacer() + + ItemView.ActionButtonHStack(viewModel: viewModel, equalSpacing: false) + .font(.title2) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift new file mode 100644 index 00000000..042bef0b --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift @@ -0,0 +1,85 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension SeriesItemView { + + struct ContentView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeriesItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + + // MARK: Episodes + + SeriesEpisodesView(viewModel: viewModel) + + // MARK: Genres + + if let genres = viewModel.item.genreItems, !genres.isEmpty { + PillHStack( + title: L10n.genres, + items: genres + ) { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } + + Divider() + } + + // MARK: Studios + + if let studios = viewModel.item.studios, !studios.isEmpty { + PillHStack( + title: L10n.studios, + items: studios + ) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + + Divider() + } + + // MARK: Cast and Crew + + if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), !castAndCrew.isEmpty { + PortraitPosterHStack( + title: L10n.castAndCrew, + items: castAndCrew + ) { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } + + Divider() + } + + // MARK: Similar + + if !viewModel.similarItems.isEmpty { + PortraitPosterHStack( + title: L10n.recommended, + items: viewModel.similarItems + ) { item in + itemRouter.route(to: \.item, item) + } + + Divider() + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift new file mode 100644 index 00000000..243dbf8c --- /dev/null +++ b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +struct SeriesItemView: View { + + @ObservedObject + var viewModel: SeriesItemViewModel + @Default(.itemViewType) + private var itemViewType + + var body: some View { + switch itemViewType { + case .compactPoster: + ItemView.CompactPosterScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + case .compactLogo: + ItemView.CompactLogoScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + case .cinematic: + ItemView.CinematicScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift new file mode 100644 index 00000000..6d6ccf45 --- /dev/null +++ b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift @@ -0,0 +1,65 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension iPadOSCollectionItemView { + + struct ContentView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: CollectionItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + + // MARK: Genres + + if let genres = viewModel.item.genreItems, !genres.isEmpty { + PillHStack( + title: L10n.genres, + items: genres + ) { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } + + Divider() + } + + // MARK: Studios + + if let studios = viewModel.item.studios { + PillHStack( + title: L10n.studios, + items: studios + ) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + + Divider() + } + + // MARK: Items + + if !viewModel.collectionItems.isEmpty { + PortraitPosterHStack( + title: L10n.items, + items: viewModel.collectionItems, + itemWidth: 130 + ) { item in + itemRouter.route(to: \.item, item) + } + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift new file mode 100644 index 00000000..e8cc46fb --- /dev/null +++ b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemView.swift @@ -0,0 +1,21 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct iPadOSCollectionItemView: View { + + @ObservedObject + var viewModel: CollectionItemViewModel + + var body: some View { + ItemView.iPadOSCinematicScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + } +} diff --git a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift new file mode 100644 index 00000000..67b45904 --- /dev/null +++ b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift @@ -0,0 +1,67 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension iPadOSEpisodeItemView { + + struct ContentView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodeItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + // MARK: Genres + + if let genres = viewModel.item.genreItems, !genres.isEmpty { + PillHStack( + title: L10n.genres, + items: genres, + selectedAction: { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } + ) + + Divider() + } + + if let studios = viewModel.item.studios, !studios.isEmpty { + PillHStack( + title: L10n.studios, + items: studios + ) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + + Divider() + } + + if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), + !castAndCrew.isEmpty + { + PortraitPosterHStack( + title: L10n.castAndCrew, + items: castAndCrew, + itemWidth: 130 + ) { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } + + Divider() + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift new file mode 100644 index 00000000..89827837 --- /dev/null +++ b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeItemView.swift @@ -0,0 +1,24 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct iPadOSEpisodeItemView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodeItemViewModel + + var body: some View { + ItemView.iPadOSCinematicScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } + } +} diff --git a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift new file mode 100644 index 00000000..dbcf1ca6 --- /dev/null +++ b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift @@ -0,0 +1,84 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension iPadOSMovieItemView { + + struct ContentView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: MovieItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + // MARK: Genres + + if let genres = viewModel.item.genreItems, !genres.isEmpty { + PillHStack( + title: L10n.genres, + items: genres + ) { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } + + Divider() + } + + // MARK: Studios + + if let studios = viewModel.item.studios, !studios.isEmpty { + PillHStack( + title: L10n.studios, + items: studios + ) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + + Divider() + } + + // MARK: Cast and Crew + + if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), + !castAndCrew.isEmpty + { + PortraitPosterHStack( + title: L10n.castAndCrew, + items: castAndCrew, + itemWidth: UIDevice.isIPad ? 130 : 110 + ) { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } + + Divider() + } + + // MARK: Similar + + if !viewModel.similarItems.isEmpty { + PortraitPosterHStack( + title: L10n.recommended, + items: viewModel.similarItems, + itemWidth: UIDevice.isIPad ? 130 : 110 + ) { item in + itemRouter.route(to: \.item, item) + } + + Divider() + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Swiftfin/Components/PortraitItemElement.swift b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemView.swift similarity index 59% rename from Swiftfin/Components/PortraitItemElement.swift rename to Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemView.swift index c04c5dc0..179eabdf 100644 --- a/Swiftfin/Components/PortraitItemElement.swift +++ b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemView.swift @@ -9,11 +9,14 @@ import JellyfinAPI import SwiftUI -// Not implemented on iOS, but used by a shared Coordinator. -struct PortraitItemElement: View { - var item: BaseItemDto +struct iPadOSMovieItemView: View { + + @ObservedObject + var viewModel: MovieItemViewModel var body: some View { - EmptyView() + ItemView.iPadOSCinematicScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } } } diff --git a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift new file mode 100644 index 00000000..56411729 --- /dev/null +++ b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift @@ -0,0 +1,165 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension ItemView { + + struct iPadOSCinematicScrollView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @State + private var scrollViewOffset: CGFloat = 0 + @ObservedObject + var viewModel: ItemViewModel + + let content: () -> Content + + private var topOpacity: CGFloat { + let start = UIScreen.main.bounds.height * 0.45 + let end = UIScreen.main.bounds.height * 0.65 + let diff = end - start + let opacity = min(max((scrollViewOffset - start) / diff, 0), 1) + return opacity + } + + @ViewBuilder + private var headerView: some View { + Group { + if viewModel.item.type == .episode { + ImageView(viewModel.item.imageSource(.primary, maxWidth: 1920)) + } else { + ImageView(viewModel.item.imageSource(.backdrop, maxWidth: 1920)) + } + } + .frame(height: UIScreen.main.bounds.height * 0.8) + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + VStack(spacing: 0) { + Spacer() + + OverlayView(viewModel: viewModel) + .padding2(.horizontal) + .padding2(.bottom) + } + .frame(height: UIScreen.main.bounds.height * 0.8) + .background { + BlurView(style: .systemThinMaterialDark) + .mask { + LinearGradient( + stops: [ + .init(color: .clear, location: 0.4), + .init(color: .white, location: 0.8), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .overlay { + Color.systemBackground + .opacity(topOpacity) + } + + content() + .padding(.vertical) + .background(Color.systemBackground) + } + } + .edgesIgnoringSafeArea(.top) + .edgesIgnoringSafeArea(.horizontal) + .scrollViewOffset($scrollViewOffset) + .navBarOffset( + $scrollViewOffset, + start: UIScreen.main.bounds.height * 0.65, + end: UIScreen.main.bounds.height * 0.65 + 50 + ) + .backgroundParallaxHeader( + $scrollViewOffset, + height: UIScreen.main.bounds.height * 0.8, + multiplier: 0.3 + ) { + headerView + } + } + } +} + +extension ItemView.iPadOSCinematicScrollView { + + struct OverlayView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading) { + ImageView( + viewModel.item.imageURL(.logo, maxWidth: 500), + resizingMode: .aspectFit + ) { + Text(viewModel.item.displayName) + .font(.largeTitle) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .foregroundColor(.white) + } + .frame(maxWidth: UIScreen.main.bounds.width * 0.4, maxHeight: 100) + + HStack(alignment: .bottom) { + + VStack(alignment: .leading) { + + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.getItemRuntime() { + Text(runtime) + } + } + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + + TruncatedTextView(text: viewModel.item.overview ?? L10n.noOverviewAvailable) { + itemRouter.route(to: \.itemOverview, viewModel.item) + } + .lineLimit(3) + .foregroundColor(.white) + + ItemView.AttributesHStack(viewModel: viewModel) + } + .padding(.trailing, 200) + + Spacer() + + VStack(spacing: 10) { + ItemView.PlayButton(viewModel: viewModel) + .frame(height: 50) + + ItemView.ActionButtonHStack(viewModel: viewModel) + .font(.title) + } + .frame(width: 250) + } + } + } + } +} diff --git a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift new file mode 100644 index 00000000..29071e05 --- /dev/null +++ b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift @@ -0,0 +1,88 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension iPadOSSeriesItemView { + + struct ContentView: View { + + @EnvironmentObject + private var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeriesItemViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + + // MARK: Episodes + + SeriesEpisodesView(viewModel: viewModel) + + // MARK: Genres + + if let genres = viewModel.item.genreItems, !genres.isEmpty { + PillHStack( + title: L10n.genres, + items: genres + ) { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } + + Divider() + } + + // MARK: Studios + + if let studios = viewModel.item.studios, !studios.isEmpty { + PillHStack( + title: L10n.studios, + items: studios + ) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + + Divider() + } + + // MARK: Cast and Crew + + if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), + !castAndCrew.isEmpty + { + PortraitPosterHStack( + title: L10n.castAndCrew, + items: castAndCrew, + itemWidth: 130 + ) { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } + + Divider() + } + + // MARK: Similar + + if !viewModel.similarItems.isEmpty { + PortraitPosterHStack( + title: L10n.recommended, + items: viewModel.similarItems, + itemWidth: 130 + ) { item in + itemRouter.route(to: \.item, item) + } + + Divider() + } + + ItemView.AboutView(viewModel: viewModel) + } + } + } +} diff --git a/Shared/Views/PortraitItemSize.swift b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemView.swift similarity index 51% rename from Shared/Views/PortraitItemSize.swift rename to Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemView.swift index 4c06f4c7..ebd36872 100644 --- a/Shared/Views/PortraitItemSize.swift +++ b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemView.swift @@ -6,13 +6,17 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import SwiftUI -extension View { +struct iPadOSSeriesItemView: View { - /// Applies Portrait Poster frame with proper corner radius ratio against the width - func portraitPoster(width: CGFloat) -> some View { - self.frame(width: width, height: width * 1.5) - .cornerRadius((width * 1.5) / 40) + @ObservedObject + var viewModel: SeriesItemViewModel + + var body: some View { + ItemView.iPadOSCinematicScrollView(viewModel: viewModel) { + ContentView(viewModel: viewModel) + } } } diff --git a/Swiftfin/Views/LatestMediaView.swift b/Swiftfin/Views/LatestMediaView.swift deleted file mode 100644 index d76658b4..00000000 --- a/Swiftfin/Views/LatestMediaView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Stinsen -import SwiftUI - -struct LatestMediaView: View { - - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @StateObject - var viewModel: LatestMediaViewModel - var topBarView: () -> TopBarView - - var body: some View { - PortraitImageHStackView( - items: viewModel.items, - horizontalAlignment: .leading - ) { - topBarView() - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } - } -} diff --git a/Swiftfin/Views/LibraryFilterView.swift b/Swiftfin/Views/LibraryFilterView.swift index 9ac7c584..06bcb4ec 100644 --- a/Swiftfin/Views/LibraryFilterView.swift +++ b/Swiftfin/Views/LibraryFilterView.swift @@ -13,7 +13,7 @@ import SwiftUI struct LibraryFilterView: View { @EnvironmentObject - var filterRouter: FilterCoordinator.Router + private var filterRouter: FilterCoordinator.Router @Binding var filters: LibraryFilters var parentId: String = "" diff --git a/Swiftfin/Views/LibraryListView.swift b/Swiftfin/Views/LibraryListView.swift index 26f22e89..31b9e12b 100644 --- a/Swiftfin/Views/LibraryListView.swift +++ b/Swiftfin/Views/LibraryListView.swift @@ -6,7 +6,6 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // -import Defaults import Foundation import JellyfinAPI import Stinsen @@ -14,21 +13,10 @@ import SwiftUI struct LibraryListView: View { @EnvironmentObject - var libraryListRouter: LibraryListCoordinator.Router + private var libraryListRouter: LibraryListCoordinator.Router @StateObject var viewModel = LibraryListViewModel() - @Default(.Experimental.liveTVAlphaEnabled) - var liveTVAlphaEnabled - - var supportedCollectionTypes: [BaseItemDto.ItemType] { - if liveTVAlphaEnabled { - return [.movie, .season, .series, .liveTV, .boxset, .unknown] - } else { - return [.movie, .season, .series, .boxset, .unknown] - } - } - var body: some View { ScrollView { LazyVStack { @@ -57,14 +45,9 @@ struct LibraryListView: View { .padding(.bottom, 5) if !viewModel.isLoading { - ForEach(viewModel.libraries.filter { [self] library in - let collectionType = library.collectionType ?? "other" - let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown - return self.supportedCollectionTypes.contains(itemType) - }, id: \.id) { library in + ForEach(viewModel.filteredLibraries, id: \.id) { library in Button { - let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown - if itemType == .liveTV { + if library.collectionType == "livetv" { libraryListRouter.route(to: \.liveTV) } else { libraryListRouter.route( @@ -77,7 +60,7 @@ struct LibraryListView: View { } } label: { ZStack { - ImageView(library.getPrimaryImage(maxWidth: 500), blurHash: library.getPrimaryImageBlurHash()) + ImageView(library.imageSource(.primary, maxWidth: 500)) .opacity(0.4) .accessibilityIgnoresInvertColors() HStack { diff --git a/Swiftfin/Views/LibrarySearchView.swift b/Swiftfin/Views/LibrarySearchView.swift index a82f7214..12a94302 100644 --- a/Swiftfin/Views/LibrarySearchView.swift +++ b/Swiftfin/Views/LibrarySearchView.swift @@ -13,7 +13,7 @@ import SwiftUI struct LibrarySearchView: View { @EnvironmentObject - var searchRouter: SearchCoordinator.Router + private var searchRouter: SearchCoordinator.Router @StateObject var viewModel: LibrarySearchViewModel @State @@ -84,7 +84,7 @@ struct LibrarySearchView: View { if !items.isEmpty { LazyVGrid(columns: tracks) { ForEach(items, id: \.id) { item in - PortraitItemButton(item: item) { item in + PortraitPosterButton(item: item) { item in searchRouter.route(to: \.item, item) } } diff --git a/Swiftfin/Views/LibraryView.swift b/Swiftfin/Views/LibraryView.swift index 89337214..0ef898b8 100644 --- a/Swiftfin/Views/LibraryView.swift +++ b/Swiftfin/Views/LibraryView.swift @@ -12,7 +12,7 @@ import SwiftUI struct LibraryView: View { @EnvironmentObject - var libraryRouter: LibraryCoordinator.Router + private var libraryRouter: LibraryCoordinator.Router @StateObject var viewModel: LibraryViewModel var title: String @@ -47,7 +47,7 @@ struct LibraryView: View { VStack { LazyVGrid(columns: tracks) { ForEach(viewModel.items, id: \.id) { item in - PortraitItemButton(item: item) { item in + PortraitPosterButton(item: item) { item in libraryRouter.route(to: \.item, item) } } diff --git a/Swiftfin/Views/LiveTVChannelItemElement.swift b/Swiftfin/Views/LiveTVChannelItemElement.swift index 941d63be..7409486b 100644 --- a/Swiftfin/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemElement.swift @@ -61,7 +61,7 @@ struct LiveTVChannelItemElement: View { Spacer() } VStack { - ImageView(channel.getPrimaryImage(maxWidth: 128)) + ImageView(channel.imageURL(.primary, maxWidth: 128)) .aspectRatio(contentMode: .fit) .frame(width: 128, alignment: .center) .padding(.init(top: 8, leading: 0, bottom: 0, trailing: 0)) diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift index 4481b361..9b1421f4 100644 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -64,7 +64,7 @@ struct LiveTVChannelItemWideElement: View { ZStack { HStack { ZStack(alignment: .center) { - ImageView(channel.getPrimaryImage(maxWidth: 128)) + ImageView(channel.imageURL(.primary, maxWidth: 128)) .aspectRatio(contentMode: .fit) .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) VStack(alignment: .center) { diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index 96164f65..1a064f07 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -16,7 +16,7 @@ typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) struct LiveTVChannelsView: View { @EnvironmentObject - var router: LiveTVCoordinator.Router + private var liveTVRouter: LiveTVCoordinator.Router @StateObject var viewModel = LiveTVChannelsViewModel() @State @@ -97,7 +97,7 @@ struct LiveTVChannelsView: View { onSelect: { loadingAction in loadingAction(true) self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in - self.router.route(to: \.videoPlayer, playerViewModel) + self.liveTVRouter.route(to: \.videoPlayer, playerViewModel) DispatchQueue.main.asyncAfter(deadline: .now() + 2) { loadingAction(false) } diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index 3cdea18b..27cd65b6 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -11,7 +11,7 @@ import SwiftUI struct LiveTVProgramsView: View { @EnvironmentObject - var programsRouter: LiveTVProgramsCoordinator.Router + private var programsRouter: LiveTVProgramsCoordinator.Router @StateObject var viewModel = LiveTVProgramsViewModel() @@ -21,15 +21,11 @@ struct LiveTVProgramsView: View { if !viewModel.recommendedItems.isEmpty, let items = viewModel.recommendedItems { - PortraitImageHStackView( - items: items, - horizontalAlignment: .leading - ) { - Text("On Now") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in + + PortraitPosterHStack( + title: "On Now", + items: items + ) { item in if let chanId = item.channelId, let chan = viewModel.findChannel(id: chanId) { @@ -42,15 +38,10 @@ struct LiveTVProgramsView: View { if !viewModel.seriesItems.isEmpty, let items = viewModel.seriesItems { - PortraitImageHStackView( - items: items, - horizontalAlignment: .leading - ) { - Text("Shows") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in + PortraitPosterHStack( + title: "Shows", + items: items + ) { item in if let chanId = item.channelId, let chan = viewModel.findChannel(id: chanId) { @@ -63,15 +54,10 @@ struct LiveTVProgramsView: View { if !viewModel.movieItems.isEmpty, let items = viewModel.movieItems { - PortraitImageHStackView( - items: items, - horizontalAlignment: .leading - ) { - Text("Movies") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in + PortraitPosterHStack( + title: "Movies", + items: items + ) { item in if let chanId = item.channelId, let chan = viewModel.findChannel(id: chanId) { @@ -84,15 +70,10 @@ struct LiveTVProgramsView: View { if !viewModel.sportsItems.isEmpty, let items = viewModel.sportsItems { - PortraitImageHStackView( - items: items, - horizontalAlignment: .leading - ) { - Text("Sports") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in + PortraitPosterHStack( + title: "Sports", + items: items + ) { item in if let chanId = item.channelId, let chan = viewModel.findChannel(id: chanId) { @@ -105,15 +86,10 @@ struct LiveTVProgramsView: View { if !viewModel.kidsItems.isEmpty, let items = viewModel.kidsItems { - PortraitImageHStackView( - items: items, - horizontalAlignment: .leading - ) { - Text("Kids") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in + PortraitPosterHStack( + title: "Kids", + items: items + ) { item in if let chanId = item.channelId, let chan = viewModel.findChannel(id: chanId) { @@ -126,15 +102,10 @@ struct LiveTVProgramsView: View { if !viewModel.newsItems.isEmpty, let items = viewModel.newsItems { - PortraitImageHStackView( - items: items, - horizontalAlignment: .leading - ) { - Text("News") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in + PortraitPosterHStack( + title: "News", + items: items + ) { item in if let chanId = item.channelId, let chan = viewModel.findChannel(id: chanId) { diff --git a/Swiftfin/Views/ServerListView.swift b/Swiftfin/Views/ServerListView.swift index 8f110cd5..0ba8526f 100644 --- a/Swiftfin/Views/ServerListView.swift +++ b/Swiftfin/Views/ServerListView.swift @@ -12,7 +12,7 @@ import SwiftUI struct ServerListView: View { @EnvironmentObject - var serverListRouter: ServerListCoordinator.Router + private var serverListRouter: ServerListCoordinator.Router @ObservedObject var viewModel: ServerListViewModel @@ -69,9 +69,11 @@ struct ServerListView: View { .frame(minWidth: 50, maxWidth: 240) .multilineTextAlignment(.center) - PrimaryButtonView(title: L10n.connect.stringValue) { + PrimaryButton(title: L10n.connect.stringValue) { serverListRouter.route(to: \.connectToServer) } + .frame(maxWidth: 300) + .frame(height: 50) } } diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView.swift index 5f9696d2..e07da362 100644 --- a/Swiftfin/Views/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView.swift @@ -14,20 +14,10 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject - var settingsRouter: SettingsCoordinator.Router + private var settingsRouter: SettingsCoordinator.Router @ObservedObject var viewModel: SettingsViewModel - @Default(.inNetworkBandwidth) - var inNetworkStreamBitrate - @Default(.outOfNetworkBandwidth) - var outOfNetworkStreamBitrate - @Default(.isAutoSelectSubtitles) - var isAutoSelectSubtitles - @Default(.autoSelectSubtitlesLangCode) - var autoSelectSubtitlesLangcode - @Default(.autoSelectAudioLangCode) - var autoSelectAudioLangcode @Default(.appAppearance) var appAppearance @Default(.overlayType) @@ -48,6 +38,8 @@ struct SettingsView: View { var resumeOffset @Default(.subtitleSize) var subtitleSize + @Default(.itemViewType) + var itemViewType @Default(.subtitleFontName) var subtitleFontName @@ -96,21 +88,6 @@ struct SettingsView: View { } } - // TODO: Implement these for playback - // Section(header: Text("Networking")) { - // Picker("Default local quality", selection: $inNetworkStreamBitrate) { - // ForEach(self.viewModel.bitrates, id: \.self) { bitrate in - // Text(bitrate.name).tag(bitrate.value) - // } - // } -// - // Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) { - // ForEach(self.viewModel.bitrates, id: \.self) { bitrate in - // Text(bitrate.name).tag(bitrate.value) - // } - // } - // } - Section(header: L10n.videoPlayer.text) { Picker(L10n.jumpForwardLength, selection: $jumpForwardLength) { ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in @@ -171,6 +148,13 @@ struct SettingsView: View { } } + // Not localized yet. Will be in a settings re-organization + Picker("Item View", selection: $itemViewType) { + ForEach(ItemViewType.allCases, id: \.self) { itemViewType in + Text(itemViewType.label).tag(itemViewType.rawValue) + } + } + Button { settingsRouter.route(to: \.missingSettings) } label: { diff --git a/Swiftfin/Views/UserListView.swift b/Swiftfin/Views/UserListView.swift index 1f585c73..b598f4a1 100644 --- a/Swiftfin/Views/UserListView.swift +++ b/Swiftfin/Views/UserListView.swift @@ -11,7 +11,7 @@ import SwiftUI struct UserListView: View { @EnvironmentObject - var userListRouter: UserListCoordinator.Router + private var userListRouter: UserListCoordinator.Router @ObservedObject var viewModel: UserListViewModel @@ -59,23 +59,11 @@ struct UserListView: View { .frame(minWidth: 50, maxWidth: 240) .multilineTextAlignment(.center) - Button { + PrimaryButton(title: L10n.signIn) { userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - ZStack { - Rectangle() - .foregroundColor(Color.jellyfinPurple) - .frame(maxWidth: 400, maxHeight: 50) - .frame(height: 50) - .cornerRadius(10) - .padding(.horizontal, 30) - .padding([.top, .bottom], 20) - - L10n.signIn.text - .foregroundColor(Color.white) - .bold() - } } + .frame(maxWidth: 300) + .frame(height: 50) } } diff --git a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index ffcc0be4..ac7cc6f6 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -487,6 +487,7 @@ struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { hlsStreamURL: URL(string: "www.apple.com")!, streamType: .direct, response: PlaybackInfoResponse(), + videoStream: MediaStream(), audioStreams: [MediaStream(displayTitle: "English", index: -1)], subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], chapters: [], diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 75d7b202..c475e43b 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ diff --git a/WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json b/WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 10fdb69d..00000000 --- a/WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.765", - "green" : "0.361", - "red" : "0.667" - } - }, - "idiom" : "iphone" - }, - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.765", - "green" : "0.361", - "red" : "0.667" - } - }, - "idiom" : "tv" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WidgetExtension/Assets.xcassets/Contents.json b/WidgetExtension/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/WidgetExtension/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json b/WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json deleted file mode 100644 index 274babba..00000000 --- a/WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WidgetExtension/Assets.xcassets/WidgetHeaderSymbol.imageset/1024.png b/WidgetExtension/Assets.xcassets/WidgetHeaderSymbol.imageset/1024.png deleted file mode 100644 index 39934f79..00000000 Binary files a/WidgetExtension/Assets.xcassets/WidgetHeaderSymbol.imageset/1024.png and /dev/null differ diff --git a/WidgetExtension/Assets.xcassets/WidgetHeaderSymbol.imageset/Contents.json b/WidgetExtension/Assets.xcassets/WidgetHeaderSymbol.imageset/Contents.json deleted file mode 100644 index d7aaf31c..00000000 --- a/WidgetExtension/Assets.xcassets/WidgetHeaderSymbol.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "1024.png", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/WidgetExtension/Info.plist b/WidgetExtension/Info.plist deleted file mode 100644 index a98e4afd..00000000 --- a/WidgetExtension/Info.plist +++ /dev/null @@ -1,29 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - $(PRODUCT_NAME) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - NSExtension - - NSExtensionPointIdentifier - com.apple.widgetkit-extension - - - diff --git a/WidgetExtension/NextUpWidget.swift b/WidgetExtension/NextUpWidget.swift deleted file mode 100644 index dee25aeb..00000000 --- a/WidgetExtension/NextUpWidget.swift +++ /dev/null @@ -1,542 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2022 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import Nuke -import SwiftUI -import WidgetKit - -enum WidgetError: String, Error { - case unknown - case emptyServer - case emptyUser - case emptyHeader -} - -struct NextUpWidgetProvider: TimelineProvider { - func placeholder(in context: Context) -> NextUpEntry { - NextUpEntry(date: Date(), items: [], error: nil) - } - - func getSnapshot(in context: Context, completion: @escaping (NextUpEntry) -> Void) { - guard let currentLogin = SessionManager.main.currentLogin else { return } - - let currentDate = Date() - let server = currentLogin.server - let savedUser = currentLogin.user - var tempCancellables = Set() - - JellyfinAPIAPI.basePath = server.currentURI - TvShowsAPI.getNextUp( - userId: savedUser.id, - limit: 3, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - imageTypeLimit: 1, - enableImageTypes: [.primary, .backdrop, .thumb] - ) - .subscribe(on: DispatchQueue.global(qos: .background)) - .sink(receiveCompletion: { result in - switch result { - case .finished: - break - case let .failure(error): - completion(NextUpEntry(date: currentDate, items: [], error: error)) - } - }, receiveValue: { response in - let dispatchGroup = DispatchGroup() - let items = response.items ?? [] - var downloadedItems = [(BaseItemDto, UIImage?)]() - items.enumerated().forEach { _, item in - dispatchGroup.enter() - ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in - guard case let .success(image) = result else { - dispatchGroup.leave() - return - } - downloadedItems.append((item, image.image)) - dispatchGroup.leave() - } - } - - dispatchGroup.notify(queue: .main) { - completion(NextUpEntry(date: currentDate, items: downloadedItems, error: nil)) - } - }) - .store(in: &tempCancellables) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - guard let currentLogin = SessionManager.main.currentLogin else { return } - - let currentDate = Date() - let entryDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! - let server = currentLogin.server - let savedUser = currentLogin.user - - var tempCancellables = Set() - - JellyfinAPIAPI.basePath = server.currentURI - TvShowsAPI.getNextUp( - userId: savedUser.id, - limit: 3, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - imageTypeLimit: 1, - enableImageTypes: [.primary, .backdrop, .thumb] - ) - .subscribe(on: DispatchQueue.global(qos: .background)) - .sink(receiveCompletion: { result in - switch result { - case .finished: - break - case let .failure(error): - completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: error)], policy: .after(entryDate))) - } - }, receiveValue: { response in - let dispatchGroup = DispatchGroup() - let items = response.items ?? [] - var downloadedItems = [(BaseItemDto, UIImage?)]() - items.enumerated().forEach { _, item in - dispatchGroup.enter() - ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in - guard case let .success(image) = result else { - dispatchGroup.leave() - return - } - downloadedItems.append((item, image.image)) - dispatchGroup.leave() - } - } - - dispatchGroup.notify(queue: .main) { - completion(Timeline( - entries: [NextUpEntry(date: currentDate, items: downloadedItems, error: nil)], - policy: .after(entryDate) - )) - } - }) - .store(in: &tempCancellables) - } -} - -struct NextUpEntry: TimelineEntry { - let date: Date - let items: [(BaseItemDto, UIImage?)] - let error: Error? -} - -struct NextUpEntryView: View { - var entry: NextUpWidgetProvider.Entry - - @Environment(\.widgetFamily) - var family - - @ViewBuilder - var body: some View { - Group { - if let error = entry.error { - HStack { - Image(systemName: "exclamationmark.octagon") - Text((error as? WidgetError)?.rawValue ?? "") - } - .background(Color.blue) - } else if entry.items.isEmpty { - L10n.emptyNextUp.text - .font(.body) - .bold() - .foregroundColor(.primary) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - } else { - switch family { - case .systemSmall: - small(item: entry.items.first) - case .systemMedium: - medium(items: entry.items) - case .systemLarge: - large(items: entry.items) - default: - EmptyView() - } - } - } - .background(Color(.secondarySystemBackground)) - } -} - -extension NextUpEntryView { - var smallVideoPlaceholderView: some View { - VStack(alignment: .leading) { - Color(.systemGray) - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .cornerRadius(8) - .shadow(radius: 8) - Color(.systemGray2) - .frame(width: 100, height: 10) - Color(.systemGray3) - .frame(width: 80, height: 10) - } - } - - var largeVideoPlaceholderView: some View { - HStack(spacing: 20) { - Color(.systemGray) - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .cornerRadius(8) - .shadow(radius: 8) - VStack(alignment: .leading, spacing: 8) { - Color(.systemGray2) - .frame(width: 100, height: 10) - Color(.systemGray3) - .frame(width: 80, height: 10) - } - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - } -} - -extension NextUpEntryView { - var headerSymbol: some View { - Image("WidgetHeaderSymbol") - .resizable() - .frame(width: 12, height: 12) - .cornerRadius(4) - .shadow(radius: 8) - } - - func smallVideoView(item: (BaseItemDto, UIImage?)) -> some View { - let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! - return Link(destination: url, label: { - VStack(alignment: .leading) { - if let image = item.1 { - Image(uiImage: image) - .resizable() - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .clipped() - .cornerRadius(8) - .shadow(radius: 8) - } - Text(item.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - Text( - "\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))" - ) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - }) - } - - func largeVideoView(item: (BaseItemDto, UIImage?)) -> some View { - let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! - return Link(destination: url, label: { - HStack(spacing: 20) { - if let image = item.1 { - Image(uiImage: image) - .resizable() - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .clipped() - .cornerRadius(8) - .shadow(radius: 8) - } - VStack(alignment: .leading, spacing: 8) { - Text(item.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - - Text( - "\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))" - ) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - } - }) - } -} - -extension NextUpEntryView { - func small(item: (BaseItemDto, UIImage?)?) -> some View { - VStack(alignment: .trailing) { - headerSymbol - if let item = item { - smallVideoView(item: item) - } else { - smallVideoPlaceholderView - } - } - .padding(12) - } - - func medium(items: [(BaseItemDto, UIImage?)]) -> some View { - VStack(alignment: .trailing) { - headerSymbol - HStack(spacing: 16) { - if let firstItem = items[safe: 0] { - smallVideoView(item: firstItem) - } else { - smallVideoPlaceholderView - } - if let secondItem = items[safe: 1] { - smallVideoView(item: secondItem) - } else { - smallVideoPlaceholderView - } - } - } - .padding(12) - } - - func large(items: [(BaseItemDto, UIImage?)]) -> some View { - VStack(spacing: 0) { - if let firstItem = items[safe: 0] { - let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(firstItem.0.id!)")! - Link( - destination: url, - label: { - ZStack(alignment: .topTrailing) { - ZStack(alignment: .bottomLeading) { - if let image = firstItem.1 { - Image(uiImage: image) - .centerCropped() - .innerShadow(color: Color.black.opacity(0.5), radius: 0.5) - } - VStack(alignment: .leading, spacing: 8) { - Text(firstItem.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.white) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - Text( - "\(firstItem.0.name ?? "") · \(L10n.seasonAndEpisode(String(firstItem.0.parentIndexNumber ?? 0), String(firstItem.0.indexNumber ?? 0)))" - ) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.gray) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - .shadow(radius: 8) - .padding(12) - } - headerSymbol - .padding(12) - } - .clipped() - .shadow(radius: 8) - } - ) - } - VStack(spacing: 8) { - if let secondItem = items[safe: 1] { - largeVideoView(item: secondItem) - } else { - largeVideoPlaceholderView - } - Divider() - if let thirdItem = items[safe: 2] { - largeVideoView(item: thirdItem) - } else { - largeVideoPlaceholderView - } - } - .padding(12) - } - } -} - -struct NextUpWidget: Widget { - let kind: String = "NextUpWidget" - - var body: some WidgetConfiguration { - StaticConfiguration( - kind: kind, - provider: NextUpWidgetProvider() - ) { entry in - NextUpEntryView(entry: entry) - } - .configurationDisplayName(L10n.nextUp) - .description("Keep watching where you left off or see what's up next.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) - } -} - -struct NextUpWidget_Previews: PreviewProvider { - static var previews: some View { - Group { - NextUpEntryView(entry: .init( - date: Date(), - items: [( - .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol") - )], - error: nil - )) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - NextUpEntryView(entry: .init( - date: Date(), - items: [ - ( - .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol") - ), - ( - .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol") - ), - ], - error: nil - )) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - NextUpEntryView(entry: .init( - date: Date(), - items: [ - ( - .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol") - ), - ( - .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol") - ), - ( - .init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), - UIImage(named: "WidgetHeaderSymbol") - ), - ], - error: nil - )) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - NextUpEntryView(entry: .init( - date: Date(), - items: [( - .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol") - )], - error: nil - )) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init( - date: Date(), - items: [ - ( - .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol") - ), - ( - .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol") - ), - ], - error: nil - )) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init( - date: Date(), - items: [ - ( - .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol") - ), - ( - .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol") - ), - ( - .init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), - UIImage(named: "WidgetHeaderSymbol") - ), - ], - error: nil - )) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init( - date: Date(), - items: [], - error: nil - )) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init( - date: Date(), - items: [ - ( - .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol") - ), - ], - error: nil - )) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init( - date: Date(), - items: [ - ( - .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol") - ), - ( - .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol") - ), - ], - error: nil - )) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - .preferredColorScheme(.dark) - } - } -} - -import SwiftUI - -private extension View { - func innerShadow(color: Color, radius: CGFloat = 0.1) -> some View { - modifier(InnerShadow(color: color, radius: min(max(0, radius), 1))) - } -} - -private struct InnerShadow: ViewModifier { - var color: Color = .gray - var radius: CGFloat = 0.1 - - private var colors: [Color] { - [color.opacity(0.75), color.opacity(0.0), .clear] - } - - func body(content: Content) -> some View { - GeometryReader { geo in - content - .overlay( - LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .top, endPoint: .bottom) - .frame(height: self.radius * self.minSide(geo)), - alignment: .top - ) - .overlay( - LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .bottom, endPoint: .top) - .frame(height: self.radius * self.minSide(geo)), - alignment: .bottom - ) - } - } - - func minSide(_ geo: GeometryProxy) -> CGFloat { - CGFloat(3) * min(geo.size.width, geo.size.height) / 2 - } -} diff --git a/swiftgen.yml b/swiftgen.yml index 9da8a089..6a5d425d 100644 --- a/swiftgen.yml +++ b/swiftgen.yml @@ -2,6 +2,4 @@ strings: inputs: Translations/en.lproj outputs: - templateName: structured-swift5 - output: Shared/Generated/Strings.swift - params: - lookupFunction: TranslationService.shared.lookupTranslation(forKey:inTable:) + output: Shared/Strings/Strings.swift