Navigation and Item Overhaul (#492)

This commit is contained in:
Ethan Pippin 2022-08-05 10:54:40 -06:00 committed by GitHub
parent faf475d185
commit a9f09edd81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
227 changed files with 8418 additions and 6399 deletions

View File

@ -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"

View File

@ -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, Collection2>(
_ 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))
}
}

View File

@ -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)) }
}

View File

@ -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<Type: BinaryInteger>(_ 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) }
}

View File

@ -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"
}
}

View File

@ -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<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ... end]
}
subscript(bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ..< end]
}
}

View File

@ -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<UInt8>,
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)
}
}

View File

@ -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) }
}

View File

@ -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 }
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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))
}

View File

@ -21,7 +21,7 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable {
@ViewBuilder
func makeAbout() -> some View {
AboutView()
AboutAppView()
}
@ViewBuilder

View File

@ -19,14 +19,18 @@ final class HomeCoordinator: NavigationCoordinatable {
var start = makeStart
@Route(.modal)
var settings = makeSettings
@Route(.push)
var library = makeLibrary
#if os(tvOS)
@Route(.modal)
var item = makeModalItem
@Route(.modal)
var library = makeModalLibrary
#else
@Route(.push)
var item = makeItem
@Route(.modal)
var modalItem = makeModalItem
@Route(.modal)
var modalLibrary = makeModalLibrary
@Route(.push)
var library = makeLibrary
#endif
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
NavigationViewCoordinator(SettingsCoordinator())
@ -50,6 +54,6 @@ final class HomeCoordinator: NavigationCoordinatable {
@ViewBuilder
func makeStart() -> some View {
HomeView()
HomeView(viewModel: .init())
}
}

View File

@ -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<VideoPlayerCoordinator> {
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
}
@ViewBuilder
func makeStart() -> some View {
ItemNavigationView(item: itemDto)
ItemView(item: itemDto)
}
}

View File

@ -23,10 +23,14 @@ final class LibraryCoordinator: NavigationCoordinatable {
var search = makeSearch
@Route(.modal)
var filter = makeFilter
#if os(tvOS)
@Route(.modal)
var item = makeModalItem
#else
@Route(.push)
var item = makeItem
@Route(.modal)
var modalItem = makeModalItem
#endif
let viewModel: LibraryViewModel
let title: String

View File

@ -63,7 +63,7 @@ final class SettingsCoordinator: NavigationCoordinatable {
@ViewBuilder
func makeAbout() -> some View {
AboutView()
AboutAppView()
}
#if !os(tvOS)

View File

@ -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<Type: BinaryInteger>(_ 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<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ... end]
}
subscript(bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ..< end]
}
}

View File

@ -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
}

0
Shared/Extensions/Defaults+Workaround.swift Normal file → Executable file
View File

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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),
]
}
}
}

View File

@ -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
}
}
}

View File

@ -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 ?? [],

View File

@ -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^"
var episodeLocator: String? {
guard let episodeNo = indexNumber else { return nil }
return L10n.episodeNumber(episodeNo)
}
return hash
}
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
}
}
}

View File

@ -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)
}
}

View File

@ -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 "(<text>)" 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
}
}

View File

@ -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)!
}
}

View File

@ -37,4 +37,9 @@ extension String {
var text: Text {
Text(self)
}
var initials: String {
let initials = self.split(separator: " ").compactMap(\.first)
return String(initials)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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<Header: View>: ViewModifier {
@Binding
var scrollViewOffset: CGFloat
let height: CGFloat
let multiplier: CGFloat
let header: () -> Header
init(
_ scrollViewOffset: Binding<CGFloat>,
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()
}
}
}

View File

@ -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
}
}
}

View File

@ -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<CGFloat>) {
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
}
}
}

View File

@ -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<M: View>(_ 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`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
@ViewBuilder
@inlinable
func `if`<Content: View>(_ 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<CGFloat>) -> some View {
self.modifier(ScrollViewOffsetModifier(scrollViewOffset: scrollViewOffset))
}
func backgroundParallaxHeader<Header: View>(
_ scrollViewOffset: Binding<CGFloat>,
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))
}
}

View File

@ -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
}
}
}

View File

@ -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<CChar>) -> 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<CChar>) -> 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<CChar>) -> String {
return L10n.tr("Localizable", "serverAlreadyConnected", p1)
}
/// Server %s already exists. Add new URL?
internal static func serverAlreadyExistsPrompt(_ p1: UnsafePointer<CChar>) -> 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<CChar>) -> 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<CChar>) -> 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)
}
}

View File

@ -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
}
}
}

View File

@ -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"
}
}
}

View File

@ -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 }
}

View File

@ -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]
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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<CChar>) -> 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<CChar>) -> 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<CChar>) -> 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<CChar>) -> 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<CChar>) -> 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<CChar>) -> 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

View File

@ -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<Bool>("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let showCastAndCrew = Key<Bool>("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let showFlattenView = Key<Bool>("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let itemViewType = Key<ItemViewType>("itemViewType", default: .compactLogo, suite: SwiftfinStore.Defaults.generalSuite)
// Video player / overlay settings
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
@ -116,5 +119,4 @@ extension Defaults.Keys {
// tvos specific
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let confirmClose = Key<Bool>("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let tvOSCinematicViews = Key<Bool>("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite)
}

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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

View File

@ -11,37 +11,20 @@ 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
$videoPlayerViewModels.sink(receiveValue: { newValue in
self.mediaDetailItems = self.createMediaDetailItems(viewModels: newValue)
})
.store(in: &cancellables)
}
@ -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
}
}

View File

@ -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<UserItemDataDto, Error>
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!
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] _ in
self?.isWatched = true
})
.store(in: &cancellables)
}
request = PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
}
func updateFavoriteState() {
if isFavorited {
UserLibraryAPI.unmarkFavoriteItem(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?.isFavorited = false
})
.store(in: &cancellables)
} 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
})
}, receiveValue: { _ in })
.store(in: &cancellables)
}
func toggleFavoriteState() {
let current = isFavorited
isFavorited.toggle()
let request: AnyPublisher<UserItemDataDto, Error>
if current {
request = UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
} else {
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

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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()

View File

@ -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,

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -14,7 +14,7 @@ import Stinsen
final class UserSignInViewModel: ViewModel {
@RouterObject
var router: UserSignInCoordinator.Router?
private var Router: UserSignInCoordinator.Router?
@Published
var publicUsers: [UserDto] = []

View File

@ -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,

View File

@ -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))
)
}
}
}

View File

@ -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)
)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -6,12 +6,13 @@
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import Foundation
import SwiftUI
struct LazyView<Content: View>: View {
var content: () -> Content
struct Divider: View {
var body: some View {
self.content()
Color.secondarySystemFill
.frame(height: 0.5)
.padding(.horizontal)
}
}

View File

@ -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<FailureView: View>: 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<FailureView: View>: 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<FailureView: View>: 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() })
}
}

View File

@ -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<Header: View, StaticOverlayView: View, Content: View>: 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)
}
}
}
}

View File

@ -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()
}
}
}

View File

@ -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: RandomAccessCollection, Content: View>(
_ data: Data,
id: KeyPath<Data.Element, Data.Element> = \.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<A: View>(
alignment: HorizontalAlignment = .leading,
@ViewBuilder content: () -> A
) {
self.alignment = alignment
self.items = [content().eraseToAnyView()]
self.restItems = Array(items.dropFirst())
}
init<A: View, B: View>(
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<A: View, B: View, C: View>(
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<A: View, B: View, C: View, D: View>(
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<A: View, B: View, C: View, D: View, E: View>(
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<A: View, B: View, C: View, D: View, E: View, F: View>(
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<A: View, B: View, C: View, D: View, E: View, F: View, G: View>(
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<A: View, B: View, C: View, D: View, E: View, F: View, G: View, H: View>(
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<A: View, B: View, C: View, D: View, E: View, F: View, G: View, H: View, I: View>(
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())
}
}

View File

@ -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()
}
}

View File

@ -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<RowManager>: 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)
}
}
}
}

View File

@ -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))

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -54,11 +54,7 @@ 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()
)
ImageView(item.imageSource(.backdrop, maxWidth: 445))
.frame(width: 445, height: 250)
.cornerRadius(10)
.ignoresSafeArea()
@ -106,13 +102,13 @@ struct LandscapeItemElement: View {
.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)
}

View File

@ -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()
}
}
}

View File

@ -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<UIScrollView?>?
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)
}
}

View File

@ -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)
}
}

View File

@ -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<Item: PortraitPoster>: 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()
}
}

View File

@ -9,7 +9,9 @@
import JellyfinAPI
import SwiftUI
// TODO: Transition to `PortraitButton`
struct PortraitItemElement: View {
@Environment(\.isFocused)
var envFocused: Bool
@State
@ -21,10 +23,7 @@ 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()
)
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)
@ -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)
}

View File

@ -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()
}
}

View File

@ -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<Item: PortraitPoster, TrailingContent: View>: 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
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -8,7 +8,7 @@
import SwiftUI
struct AboutView: View {
struct AboutAppView: View {
var body: some View {
Text("dud")

View File

@ -13,7 +13,7 @@ import SwiftUI
struct BasicAppSettingsView: View {
@EnvironmentObject
var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router
private var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router
@ObservedObject
var viewModel: BasicAppSettingsViewModel
@State

View File

@ -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))
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)

View File

@ -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 {

View File

@ -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
if !viewModel.latestAddedItems.isEmpty {
PortraitPosterHStack(
title: L10n.recentlyAdded,
items: viewModel.latestAddedItems
) { item in
homeRouter.route(to: \.modalItem, item)
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)

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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<Content: View>: 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
}
}
}
}
}

View File

@ -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()
}
}
}

Some files were not shown because too many files have changed in this diff Show More