mirror of
https://github.com/jellyfin/Swiftfin.git
synced 2024-11-23 05:59:51 +00:00
Navigation and Item Overhaul (#492)
This commit is contained in:
parent
faf475d185
commit
a9f09edd81
@ -43,6 +43,6 @@
|
|||||||
redundantClosure, \
|
redundantClosure, \
|
||||||
redundantType
|
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"
|
--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"
|
||||||
|
63
Shared/BlurHashKit/BlurHash.swift
Executable file
63
Shared/BlurHashKit/BlurHash.swift
Executable 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))
|
||||||
|
}
|
||||||
|
}
|
102
Shared/BlurHashKit/ColourProbes.swift
Executable file
102
Shared/BlurHashKit/ColourProbes.swift
Executable 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)) }
|
||||||
|
}
|
25
Shared/BlurHashKit/ColourSpace.swift
Executable file
25
Shared/BlurHashKit/ColourSpace.swift
Executable 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) }
|
||||||
|
}
|
43
Shared/BlurHashKit/EscapeSequences.swift
Executable file
43
Shared/BlurHashKit/EscapeSequences.swift
Executable 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"
|
||||||
|
}
|
||||||
|
}
|
76
Shared/BlurHashKit/FromString.swift
Executable file
76
Shared/BlurHashKit/FromString.swift
Executable 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]
|
||||||
|
}
|
||||||
|
}
|
92
Shared/BlurHashKit/FromUIImage.swift
Executable file
92
Shared/BlurHashKit/FromUIImage.swift
Executable 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)
|
||||||
|
}
|
||||||
|
}
|
115
Shared/BlurHashKit/Generation.swift
Executable file
115
Shared/BlurHashKit/Generation.swift
Executable 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) }
|
||||||
|
}
|
48
Shared/BlurHashKit/StringCoding.swift
Executable file
48
Shared/BlurHashKit/StringCoding.swift
Executable 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 }
|
||||||
|
}
|
56
Shared/BlurHashKit/ToString.swift
Executable file
56
Shared/BlurHashKit/ToString.swift
Executable 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
|
||||||
|
}
|
||||||
|
}
|
101
Shared/BlurHashKit/ToUIImage.swift
Executable file
101
Shared/BlurHashKit/ToUIImage.swift
Executable 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)
|
||||||
|
}
|
||||||
|
}
|
61
Shared/BlurHashKit/TupleMaths.swift
Executable file
61
Shared/BlurHashKit/TupleMaths.swift
Executable 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))
|
||||||
|
}
|
@ -21,7 +21,7 @@ final class BasicAppSettingsCoordinator: NavigationCoordinatable {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeAbout() -> some View {
|
func makeAbout() -> some View {
|
||||||
AboutView()
|
AboutAppView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -19,14 +19,18 @@ final class HomeCoordinator: NavigationCoordinatable {
|
|||||||
var start = makeStart
|
var start = makeStart
|
||||||
@Route(.modal)
|
@Route(.modal)
|
||||||
var settings = makeSettings
|
var settings = makeSettings
|
||||||
@Route(.push)
|
|
||||||
var library = makeLibrary
|
#if os(tvOS)
|
||||||
@Route(.push)
|
@Route(.modal)
|
||||||
var item = makeItem
|
var item = makeModalItem
|
||||||
@Route(.modal)
|
@Route(.modal)
|
||||||
var modalItem = makeModalItem
|
var library = makeModalLibrary
|
||||||
@Route(.modal)
|
#else
|
||||||
var modalLibrary = makeModalLibrary
|
@Route(.push)
|
||||||
|
var item = makeItem
|
||||||
|
@Route(.push)
|
||||||
|
var library = makeLibrary
|
||||||
|
#endif
|
||||||
|
|
||||||
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
|
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
|
||||||
NavigationViewCoordinator(SettingsCoordinator())
|
NavigationViewCoordinator(SettingsCoordinator())
|
||||||
@ -50,6 +54,6 @@ final class HomeCoordinator: NavigationCoordinatable {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeStart() -> some View {
|
func makeStart() -> some View {
|
||||||
HomeView()
|
HomeView(viewModel: .init())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,12 +44,16 @@ final class ItemCoordinator: NavigationCoordinatable {
|
|||||||
NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto))
|
NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeSeason(item: BaseItemDto) -> ItemCoordinator {
|
||||||
|
ItemCoordinator(item: item)
|
||||||
|
}
|
||||||
|
|
||||||
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
||||||
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
|
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeStart() -> some View {
|
func makeStart() -> some View {
|
||||||
ItemNavigationView(item: itemDto)
|
ItemView(item: itemDto)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,10 +23,14 @@ final class LibraryCoordinator: NavigationCoordinatable {
|
|||||||
var search = makeSearch
|
var search = makeSearch
|
||||||
@Route(.modal)
|
@Route(.modal)
|
||||||
var filter = makeFilter
|
var filter = makeFilter
|
||||||
@Route(.push)
|
|
||||||
var item = makeItem
|
#if os(tvOS)
|
||||||
@Route(.modal)
|
@Route(.modal)
|
||||||
var modalItem = makeModalItem
|
var item = makeModalItem
|
||||||
|
#else
|
||||||
|
@Route(.push)
|
||||||
|
var item = makeItem
|
||||||
|
#endif
|
||||||
|
|
||||||
let viewModel: LibraryViewModel
|
let viewModel: LibraryViewModel
|
||||||
let title: String
|
let title: String
|
||||||
|
@ -63,7 +63,7 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func makeAbout() -> some View {
|
func makeAbout() -> some View {
|
||||||
AboutView()
|
AboutAppView()
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
@ -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]
|
|
||||||
}
|
|
||||||
}
|
|
@ -20,8 +20,8 @@ public extension Color {
|
|||||||
#else
|
#else
|
||||||
static let systemFill = Color(UIColor.systemFill)
|
static let systemFill = Color(UIColor.systemFill)
|
||||||
static let systemBackground = Color(UIColor.systemBackground)
|
static let systemBackground = Color(UIColor.systemBackground)
|
||||||
static let secondarySystemFill = Color(UIColor.secondarySystemBackground)
|
static let secondarySystemFill = Color(UIColor.secondarySystemFill)
|
||||||
static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground)
|
static let tertiarySystemFill = Color(UIColor.tertiarySystemFill)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
0
Shared/Extensions/Defaults+Workaround.swift
Normal file → Executable file
0
Shared/Extensions/Defaults+Workaround.swift
Normal file → Executable file
42
Shared/Extensions/FontExtensions.swift
Normal file
42
Shared/Extensions/FontExtensions.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -125,21 +125,21 @@ extension BaseItemDto {
|
|||||||
modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
|
modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
|
||||||
|
|
||||||
// TODO: other forms of media subtitle
|
// TODO: other forms of media subtitle
|
||||||
if self.itemType == .episode {
|
if self.type == .episode {
|
||||||
if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() {
|
if let seriesName = self.seriesName, let episodeLocator = self.episodeLocator {
|
||||||
subtitle = "\(seriesName) - \(episodeLocator)"
|
subtitle = "\(seriesName) - \(episodeLocator)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let subtitlesEnabled = defaultSubtitleStream != nil
|
let subtitlesEnabled = defaultSubtitleStream != nil
|
||||||
|
|
||||||
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
|
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && type == .episode
|
||||||
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
|
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
|
||||||
|
|
||||||
let overlayType = Defaults[.overlayType]
|
let overlayType = Defaults[.overlayType]
|
||||||
|
|
||||||
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode
|
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && type == .episode
|
||||||
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode
|
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && type == .episode
|
||||||
|
|
||||||
var fileName: String?
|
var fileName: String?
|
||||||
if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
|
if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
|
||||||
@ -155,6 +155,7 @@ extension BaseItemDto {
|
|||||||
hlsStreamURL: hlsStreamURL,
|
hlsStreamURL: hlsStreamURL,
|
||||||
streamType: streamType,
|
streamType: streamType,
|
||||||
response: response,
|
response: response,
|
||||||
|
videoStream: videoStream!,
|
||||||
audioStreams: audioStreams,
|
audioStreams: audioStreams,
|
||||||
subtitleStreams: subtitleStreams,
|
subtitleStreams: subtitleStreams,
|
||||||
chapters: modifiedSelfItem.chapters ?? [],
|
chapters: modifiedSelfItem.chapters ?? [],
|
||||||
@ -292,21 +293,21 @@ extension BaseItemDto {
|
|||||||
modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
|
modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
|
||||||
|
|
||||||
// TODO: other forms of media subtitle
|
// TODO: other forms of media subtitle
|
||||||
if self.itemType == .episode {
|
if self.type == .episode {
|
||||||
if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() {
|
if let seriesName = self.seriesName, let episodeLocator = self.episodeLocator {
|
||||||
subtitle = "\(seriesName) - \(episodeLocator)"
|
subtitle = "\(seriesName) - \(episodeLocator)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let subtitlesEnabled = defaultSubtitleStream != nil
|
let subtitlesEnabled = defaultSubtitleStream != nil
|
||||||
|
|
||||||
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
|
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && type == .episode
|
||||||
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
|
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
|
||||||
|
|
||||||
let overlayType = Defaults[.overlayType]
|
let overlayType = Defaults[.overlayType]
|
||||||
|
|
||||||
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode
|
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && type == .episode
|
||||||
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode
|
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && type == .episode
|
||||||
|
|
||||||
var fileName: String?
|
var fileName: String?
|
||||||
if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
|
if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
|
||||||
@ -322,6 +323,7 @@ extension BaseItemDto {
|
|||||||
hlsStreamURL: hlsStreamURL,
|
hlsStreamURL: hlsStreamURL,
|
||||||
streamType: streamType,
|
streamType: streamType,
|
||||||
response: response,
|
response: response,
|
||||||
|
videoStream: videoStream!,
|
||||||
audioStreams: audioStreams,
|
audioStreams: audioStreams,
|
||||||
subtitleStreams: subtitleStreams,
|
subtitleStreams: subtitleStreams,
|
||||||
chapters: modifiedSelfItem.chapters ?? [],
|
chapters: modifiedSelfItem.chapters ?? [],
|
||||||
|
@ -10,175 +10,22 @@ import Foundation
|
|||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// 001fC^ = dark grey plain blurhash
|
extension BaseItemDto: Identifiable {}
|
||||||
|
|
||||||
public extension BaseItemDto {
|
extension BaseItemDto {
|
||||||
// MARK: Images
|
|
||||||
|
|
||||||
func getSeriesBackdropImageBlurHash() -> String {
|
var episodeLocator: String? {
|
||||||
let imgURL = getSeriesBackdropImage(maxWidth: 1)
|
guard let episodeNo = indexNumber else { return nil }
|
||||||
guard let imgTag = imgURL.queryParameters?["tag"],
|
return L10n.episodeNumber(episodeNo)
|
||||||
let hash = imageBlurHashes?.backdrop?[imgTag]
|
|
||||||
else {
|
|
||||||
return "001fC^"
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSeriesPrimaryImageBlurHash() -> String {
|
var seasonEpisodeLocator: 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? {
|
|
||||||
if let seasonNo = parentIndexNumber, let episodeNo = indexNumber {
|
if let seasonNo = parentIndexNumber, let episodeNo = indexNumber {
|
||||||
return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo))
|
return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo))
|
||||||
}
|
}
|
||||||
return nil
|
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
|
// MARK: Calculations
|
||||||
|
|
||||||
func getItemRuntime() -> String? {
|
func getItemRuntime() -> String? {
|
||||||
@ -238,61 +85,8 @@ public extension BaseItemDto {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: ItemType
|
var displayName: String {
|
||||||
|
name ?? "--"
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: ItemDetail
|
// MARK: ItemDetail
|
||||||
@ -329,13 +123,13 @@ public extension BaseItemDto {
|
|||||||
|
|
||||||
if !audioStreams.isEmpty {
|
if !audioStreams.isEmpty {
|
||||||
let audioList = audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
|
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))
|
mediaItems.append(ItemDetail(title: L10n.audio, content: audioList))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !subtitleStreams.isEmpty {
|
if !subtitleStreams.isEmpty {
|
||||||
let subtitleList = subtitleStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
|
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))
|
mediaItems.append(ItemDetail(title: L10n.subtitles, content: subtitleList))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -343,6 +137,14 @@ public extension BaseItemDto {
|
|||||||
return mediaItems
|
return mediaItems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var subtitleStreams: [MediaStream] {
|
||||||
|
mediaStreams?.filter { $0.type == .subtitle } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioStreams: [MediaStream] {
|
||||||
|
mediaStreams?.filter { $0.type == .audio } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Missing and Unaired
|
// MARK: Missing and Unaired
|
||||||
|
|
||||||
var missing: Bool {
|
var missing: Bool {
|
||||||
@ -370,6 +172,13 @@ public extension BaseItemDto {
|
|||||||
return dateFormatter.string(from: premiereDate)
|
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
|
// MARK: Chapter Images
|
||||||
|
|
||||||
func getChapterImage(maxWidth: Int) -> [URL] {
|
func getChapterImage(maxWidth: Int) -> [URL] {
|
||||||
@ -389,4 +198,52 @@ public extension BaseItemDto {
|
|||||||
|
|
||||||
return chapterImageURLs
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -12,39 +12,13 @@ import UIKit
|
|||||||
|
|
||||||
extension BaseItemPerson {
|
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
|
// MARK: First Role
|
||||||
|
|
||||||
// Jellyfin will grab all roles the person played in the show which makes the 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:
|
// text too long. This will grab the first role which:
|
||||||
// - assumes that the most important role is the first
|
// - assumes that the most important role is the first
|
||||||
// - will also grab the last "(<text>)" instance, like "(voice)"
|
// - will also grab the last "(<text>)" instance, like "(voice)"
|
||||||
func firstRole() -> String? {
|
var firstRole: String? {
|
||||||
guard let role = self.role else { return nil }
|
guard let role = self.role else { return nil }
|
||||||
let split = role.split(separator: "/")
|
let split = role.split(separator: "/")
|
||||||
guard split.count > 1 else { return role }
|
guard split.count > 1 else { return role }
|
||||||
@ -61,56 +35,18 @@ extension BaseItemPerson {
|
|||||||
|
|
||||||
return final
|
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.
|
// Only displayed person types.
|
||||||
// Will ignore people like "GuestStar"
|
// Will ignore types like "GuestStar"
|
||||||
enum DisplayedType: String, CaseIterable {
|
enum DisplayedType: String {
|
||||||
case actor = "Actor"
|
case actor = "Actor"
|
||||||
case director = "Director"
|
case director = "Director"
|
||||||
case writer = "Writer"
|
case writer = "Writer"
|
||||||
case producer = "Producer"
|
case producer = "Producer"
|
||||||
|
}
|
||||||
|
|
||||||
static var allCasesRaw: [String] {
|
var isDisplayed: Bool {
|
||||||
self.allCases.map(\.rawValue)
|
guard let type = type else { return false }
|
||||||
}
|
return DisplayedType(rawValue: type) != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,10 +7,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import JellyfinAPI
|
||||||
|
|
||||||
extension View {
|
extension RequestBuilder where T == URL {
|
||||||
func eraseToAnyView() -> AnyView {
|
var url: URL {
|
||||||
AnyView(self)
|
URL(string: URLString)!
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -37,4 +37,9 @@ extension String {
|
|||||||
var text: Text {
|
var text: Text {
|
||||||
Text(self)
|
Text(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var initials: String {
|
||||||
|
let initials = self.split(separator: " ").compactMap(\.first)
|
||||||
|
return String(initials)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,4 +12,32 @@ extension UIDevice {
|
|||||||
static var vendorUUIDString: String {
|
static var vendorUUIDString: String {
|
||||||
current.identifierForVendor!.uuidString
|
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
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,14 @@
|
|||||||
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import UIKit
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
@main
|
extension UIScreen {
|
||||||
struct JellyfinWidgetBundle: WidgetBundle {
|
func scale(_ x: Int) -> Int {
|
||||||
@WidgetBundleBuilder
|
Int(nativeScale) * x
|
||||||
var body: some Widget {
|
}
|
||||||
NextUpWidget()
|
|
||||||
|
func scale(_ x: CGFloat) -> Int {
|
||||||
|
Int(nativeScale * x)
|
||||||
}
|
}
|
||||||
}
|
}
|
16
Shared/Extensions/UIScrollViewExtensions.swift
Normal file
16
Shared/Extensions/UIScrollViewExtensions.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
77
Shared/Extensions/ViewExtensions/ViewExtensions.swift
Normal file
77
Shared/Extensions/ViewExtensions/ViewExtensions.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
27
Shared/Objects/ItemViewType.swift
Normal file
27
Shared/Objects/ItemViewType.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 }
|
|
||||||
}
|
|
32
Shared/Objects/Poster.swift
Normal file
32
Shared/Objects/Poster.swift
Normal 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]
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
488
Shared/Strings/Strings.swift
Normal file
488
Shared/Strings/Strings.swift
Normal 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
|
@ -10,6 +10,8 @@ import Defaults
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
// TODO: Refactor...
|
||||||
|
|
||||||
extension SwiftfinStore {
|
extension SwiftfinStore {
|
||||||
enum Defaults {
|
enum Defaults {
|
||||||
static let generalSuite: UserDefaults = .init(suiteName: "swiftfinstore-general-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 showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite)
|
||||||
static let showCastAndCrew = Key<Bool>("showCastAndCrew", 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 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
|
// Video player / overlay settings
|
||||||
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
|
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
|
||||||
@ -116,5 +119,4 @@ extension Defaults.Keys {
|
|||||||
// tvos specific
|
// tvos specific
|
||||||
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite)
|
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 confirmClose = Key<Bool>("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite)
|
||||||
static let tvOSCinematicViews = Key<Bool>("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite)
|
|
||||||
}
|
}
|
||||||
|
@ -14,9 +14,11 @@ protocol EpisodesRowManager: ViewModel {
|
|||||||
var item: BaseItemDto { get }
|
var item: BaseItemDto { get }
|
||||||
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set }
|
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set }
|
||||||
var selectedSeason: 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(season: BaseItemDto)
|
||||||
|
func select(seasonID: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EpisodesRowManager {
|
extension EpisodesRowManager {
|
||||||
@ -25,10 +27,19 @@ extension EpisodesRowManager {
|
|||||||
Array(seasonsEpisodes.keys).sorted(by: { $0.indexNumber ?? 0 < $1.indexNumber ?? 0 })
|
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
|
// Also retrieves the current season episodes if available
|
||||||
func retrieveSeasons() {
|
func getSeasons() {
|
||||||
TvShowsAPI.getSeasons(
|
TvShowsAPI.getSeasons(
|
||||||
seriesId: item.seriesId ?? "",
|
seriesId: item.id ?? "",
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
userId: SessionManager.main.currentLogin.user.id,
|
||||||
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false
|
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false
|
||||||
)
|
)
|
||||||
@ -36,26 +47,21 @@ extension EpisodesRowManager {
|
|||||||
self.handleAPIRequestError(completion: completion)
|
self.handleAPIRequestError(completion: completion)
|
||||||
} receiveValue: { response in
|
} receiveValue: { response in
|
||||||
let seasons = response.items ?? []
|
let seasons = response.items ?? []
|
||||||
|
|
||||||
seasons.forEach { season in
|
seasons.forEach { season in
|
||||||
self.seasonsEpisodes[season] = []
|
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)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func retrieveEpisodesForSeason(_ season: BaseItemDto) {
|
func getEpisodesForSeason(_ season: BaseItemDto) {
|
||||||
guard let seasonID = season.id else { return }
|
guard let seasonID = season.id else { return }
|
||||||
|
|
||||||
TvShowsAPI.getEpisodes(
|
TvShowsAPI.getEpisodes(
|
||||||
seriesId: item.seriesId ?? "",
|
seriesId: item.id ?? "",
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
userId: SessionManager.main.currentLogin.user.id,
|
||||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||||
seasonId: seasonID,
|
seasonId: seasonID,
|
||||||
@ -74,7 +80,12 @@ extension EpisodesRowManager {
|
|||||||
self.selectedSeason = season
|
self.selectedSeason = season
|
||||||
|
|
||||||
if seasonsEpisodes[season]!.isEmpty {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ final class HomeViewModel: ViewModel {
|
|||||||
var libraries: [BaseItemDto] = []
|
var libraries: [BaseItemDto] = []
|
||||||
|
|
||||||
// temp
|
// temp
|
||||||
var recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded])
|
static let recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded])
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@ -139,7 +139,7 @@ final class HomeViewModel: ViewModel {
|
|||||||
includeItemTypes: [.movie, .series],
|
includeItemTypes: [.movie, .series],
|
||||||
enableImageTypes: [.primary, .backdrop, .thumb],
|
enableImageTypes: [.primary, .backdrop, .thumb],
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
limit: 8
|
limit: 20
|
||||||
)
|
)
|
||||||
.sink { completion in
|
.sink { completion in
|
||||||
switch completion {
|
switch completion {
|
||||||
@ -161,7 +161,7 @@ final class HomeViewModel: ViewModel {
|
|||||||
private func refreshResumeItems() {
|
private func refreshResumeItems() {
|
||||||
ItemsAPI.getResumeItems(
|
ItemsAPI.getResumeItems(
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
userId: SessionManager.main.currentLogin.user.id,
|
||||||
limit: 6,
|
limit: 20,
|
||||||
fields: [
|
fields: [
|
||||||
.primaryImageAspectRatio,
|
.primaryImageAspectRatio,
|
||||||
.seriesPrimaryImage,
|
.seriesPrimaryImage,
|
||||||
@ -210,7 +210,7 @@ final class HomeViewModel: ViewModel {
|
|||||||
private func refreshNextUpItems() {
|
private func refreshNextUpItems() {
|
||||||
TvShowsAPI.getNextUp(
|
TvShowsAPI.getNextUp(
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
userId: SessionManager.main.currentLogin.user.id,
|
||||||
limit: 6,
|
limit: 20,
|
||||||
fields: [
|
fields: [
|
||||||
.primaryImageAspectRatio,
|
.primaryImageAspectRatio,
|
||||||
.seriesPrimaryImage,
|
.seriesPrimaryImage,
|
||||||
|
@ -25,7 +25,7 @@ final class CollectionItemViewModel: ItemViewModel {
|
|||||||
ItemsAPI.getItems(
|
ItemsAPI.getItems(
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
userId: SessionManager.main.currentLogin.user.id,
|
||||||
parentId: item.id,
|
parentId: item.id,
|
||||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]
|
fields: ItemFields.allCases
|
||||||
)
|
)
|
||||||
.trackActivity(loading)
|
.trackActivity(loading)
|
||||||
.sink { [weak self] completion in
|
.sink { [weak self] completion in
|
||||||
|
@ -11,39 +11,22 @@ import Foundation
|
|||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import Stinsen
|
import Stinsen
|
||||||
|
|
||||||
final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager {
|
final class EpisodeItemViewModel: ItemViewModel {
|
||||||
|
|
||||||
@RouterObject
|
@RouterObject
|
||||||
var itemRouter: ItemCoordinator.Router?
|
private var itemRouter: ItemCoordinator.Router?
|
||||||
@Published
|
@Published
|
||||||
var series: BaseItemDto?
|
var playButtonText: String = ""
|
||||||
@Published
|
@Published
|
||||||
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
|
var mediaDetailItems: [[BaseItemDto.ItemDetail]] = []
|
||||||
@Published
|
|
||||||
var selectedSeason: BaseItemDto?
|
|
||||||
|
|
||||||
override init(item: BaseItemDto) {
|
override init(item: BaseItemDto) {
|
||||||
super.init(item: item)
|
super.init(item: item)
|
||||||
|
|
||||||
getEpisodeSeries()
|
$videoPlayerViewModels.sink(receiveValue: { newValue in
|
||||||
retrieveSeasons()
|
self.mediaDetailItems = self.createMediaDetailItems(viewModels: newValue)
|
||||||
}
|
})
|
||||||
|
.store(in: &cancellables)
|
||||||
override func getItemDisplayName() -> String {
|
|
||||||
guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" }
|
|
||||||
return "\(episodeLocator)\n\(item.name ?? "")"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEpisodeSeries() {
|
|
||||||
guard let id = item.seriesId else { return }
|
|
||||||
UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id)
|
|
||||||
.trackActivity(loading)
|
|
||||||
.sink(receiveCompletion: { [weak self] completion in
|
|
||||||
self?.handleAPIRequestError(completion: completion)
|
|
||||||
}, receiveValue: { [weak self] item in
|
|
||||||
self?.series = item
|
|
||||||
})
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func updateItem() {
|
override func updateItem() {
|
||||||
@ -72,4 +55,30 @@ final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,15 +31,15 @@ class ItemViewModel: ViewModel {
|
|||||||
@Published
|
@Published
|
||||||
var isFavorited = false
|
var isFavorited = false
|
||||||
@Published
|
@Published
|
||||||
var informationItems: [BaseItemDto.ItemDetail]
|
|
||||||
@Published
|
|
||||||
var selectedVideoPlayerViewModel: VideoPlayerViewModel?
|
var selectedVideoPlayerViewModel: VideoPlayerViewModel?
|
||||||
|
@Published
|
||||||
var videoPlayerViewModels: [VideoPlayerViewModel] = []
|
var videoPlayerViewModels: [VideoPlayerViewModel] = []
|
||||||
|
|
||||||
init(item: BaseItemDto) {
|
init(item: BaseItemDto) {
|
||||||
self.item = item
|
self.item = item
|
||||||
|
super.init()
|
||||||
|
|
||||||
switch item.itemType {
|
switch item.type {
|
||||||
case .episode, .movie:
|
case .episode, .movie:
|
||||||
if !item.missing && !item.unaired {
|
if !item.missing && !item.unaired {
|
||||||
self.playButtonItem = item
|
self.playButtonItem = item
|
||||||
@ -47,17 +47,13 @@ class ItemViewModel: ViewModel {
|
|||||||
default: ()
|
default: ()
|
||||||
}
|
}
|
||||||
|
|
||||||
informationItems = item.createInformationItems()
|
|
||||||
|
|
||||||
isFavorited = item.userData?.isFavorite ?? false
|
isFavorited = item.userData?.isFavorite ?? false
|
||||||
isWatched = item.userData?.played ?? false
|
isWatched = item.userData?.played ?? false
|
||||||
super.init()
|
|
||||||
|
|
||||||
getSimilarItems()
|
getSimilarItems()
|
||||||
|
refreshItemVideoPlayerViewModel(for: item)
|
||||||
|
|
||||||
Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:)))
|
Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:)))
|
||||||
|
|
||||||
refreshItemVideoPlayerViewModel(for: item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
@ -74,7 +70,7 @@ class ItemViewModel: ViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func refreshItemVideoPlayerViewModel(for item: BaseItemDto) {
|
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 }
|
guard !item.missing, !item.unaired else { return }
|
||||||
|
|
||||||
item.createVideoPlayerViewModel()
|
item.createVideoPlayerViewModel()
|
||||||
@ -104,20 +100,12 @@ class ItemViewModel: ViewModel {
|
|||||||
return L10n.play
|
return L10n.play
|
||||||
}
|
}
|
||||||
|
|
||||||
func getItemDisplayName() -> String {
|
|
||||||
item.name ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldDisplayRuntime() -> Bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSimilarItems() {
|
func getSimilarItems() {
|
||||||
LibraryAPI.getSimilarItems(
|
LibraryAPI.getSimilarItems(
|
||||||
itemId: item.id!,
|
itemId: item.id!,
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
userId: SessionManager.main.currentLogin.user.id,
|
||||||
limit: 10,
|
limit: 20,
|
||||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]
|
fields: ItemFields.allCases
|
||||||
)
|
)
|
||||||
.trackActivity(loading)
|
.trackActivity(loading)
|
||||||
.sink(receiveCompletion: { [weak self] completion in
|
.sink(receiveCompletion: { [weak self] completion in
|
||||||
@ -128,54 +116,52 @@ class ItemViewModel: ViewModel {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateWatchState() {
|
func toggleWatchState() {
|
||||||
if isWatched {
|
let current = isWatched
|
||||||
PlaystateAPI.markUnplayedItem(
|
isWatched.toggle()
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
let request: AnyPublisher<UserItemDataDto, Error>
|
||||||
itemId: item.id!
|
|
||||||
)
|
if current {
|
||||||
.trackActivity(loading)
|
request = PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||||
.sink(receiveCompletion: { [weak self] completion in
|
|
||||||
self?.handleAPIRequestError(completion: completion)
|
|
||||||
}, receiveValue: { [weak self] _ in
|
|
||||||
self?.isWatched = false
|
|
||||||
})
|
|
||||||
.store(in: &cancellables)
|
|
||||||
} else {
|
} else {
|
||||||
PlaystateAPI.markPlayedItem(
|
request = PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
}
|
||||||
itemId: item.id!
|
|
||||||
)
|
request
|
||||||
.trackActivity(loading)
|
.trackActivity(loading)
|
||||||
.sink(receiveCompletion: { [weak self] completion in
|
.sink(receiveCompletion: { [weak self] completion in
|
||||||
|
switch completion {
|
||||||
|
case .failure:
|
||||||
|
self?.isWatched = !current
|
||||||
|
case .finished: ()
|
||||||
|
}
|
||||||
self?.handleAPIRequestError(completion: completion)
|
self?.handleAPIRequestError(completion: completion)
|
||||||
}, receiveValue: { [weak self] _ in
|
}, receiveValue: { _ in })
|
||||||
self?.isWatched = true
|
|
||||||
})
|
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateFavoriteState() {
|
func toggleFavoriteState() {
|
||||||
if isFavorited {
|
let current = isFavorited
|
||||||
UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
isFavorited.toggle()
|
||||||
.trackActivity(loading)
|
let request: AnyPublisher<UserItemDataDto, Error>
|
||||||
.sink(receiveCompletion: { [weak self] completion in
|
|
||||||
self?.handleAPIRequestError(completion: completion)
|
if current {
|
||||||
}, receiveValue: { [weak self] _ in
|
request = UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||||
self?.isFavorited = false
|
|
||||||
})
|
|
||||||
.store(in: &cancellables)
|
|
||||||
} else {
|
} else {
|
||||||
UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
request = UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||||
.trackActivity(loading)
|
|
||||||
.sink(receiveCompletion: { [weak self] completion in
|
|
||||||
self?.handleAPIRequestError(completion: completion)
|
|
||||||
}, receiveValue: { [weak self] _ in
|
|
||||||
self?.isFavorited = true
|
|
||||||
})
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request
|
||||||
|
.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
|
// Overridden by subclasses
|
||||||
|
@ -14,11 +14,7 @@ import Stinsen
|
|||||||
final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||||
|
|
||||||
@RouterObject
|
@RouterObject
|
||||||
var itemRouter: ItemCoordinator.Router?
|
private var itemRouter: ItemCoordinator.Router?
|
||||||
@Published
|
|
||||||
var episodes: [BaseItemDto] = []
|
|
||||||
@Published
|
|
||||||
var seriesItem: BaseItemDto?
|
|
||||||
@Published
|
@Published
|
||||||
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
|
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
|
||||||
@Published
|
@Published
|
||||||
@ -27,9 +23,8 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
|||||||
override init(item: BaseItemDto) {
|
override init(item: BaseItemDto) {
|
||||||
super.init(item: item)
|
super.init(item: item)
|
||||||
|
|
||||||
getSeriesItem()
|
|
||||||
selectedSeason = item
|
selectedSeason = item
|
||||||
retrieveSeasons()
|
// getSeasons()
|
||||||
requestEpisodes()
|
requestEpisodes()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +34,7 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
|||||||
return L10n.unaired
|
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
|
return episodeLocator
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,8 +52,7 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
|||||||
self?.handleAPIRequestError(completion: completion)
|
self?.handleAPIRequestError(completion: completion)
|
||||||
}, receiveValue: { [weak self] response in
|
}, receiveValue: { [weak self] response in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.episodes = response.items ?? []
|
self.seasonsEpisodes[self.item] = response.items ?? []
|
||||||
LogManager.log.debug("Retrieved \(String(self.episodes.count)) episodes")
|
|
||||||
|
|
||||||
self.setNextUpInSeason()
|
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!)")
|
LogManager.log.debug("Nextup in season \(self.item.id!) (\(self.item.name!)): \(nextUpItem.id!)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.playButtonItem == nil && !self.episodes.isEmpty {
|
// if self.playButtonItem == nil && !self.episodes.isEmpty {
|
||||||
// Fallback to the old mechanism:
|
// // Fallback to the old mechanism:
|
||||||
// Sets the play button item to the "Next up" in the season based upon
|
// // Sets the play button item to the "Next up" in the season based upon
|
||||||
// the watched status of episodes in the season.
|
// // the watched status of episodes in the season.
|
||||||
// Default to the first episode of the season if all have been watched.
|
// // Default to the first episode of the season if all have been watched.
|
||||||
var firstUnwatchedSearch: BaseItemDto?
|
// var firstUnwatchedSearch: BaseItemDto?
|
||||||
|
//
|
||||||
for episode in self.episodes {
|
// for episode in self.episodes {
|
||||||
guard let played = episode.userData?.played else { continue }
|
// guard let played = episode.userData?.played else { continue }
|
||||||
if !played {
|
// if !played {
|
||||||
firstUnwatchedSearch = episode
|
// firstUnwatchedSearch = episode
|
||||||
break
|
// break
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
if let firstUnwatched = firstUnwatchedSearch {
|
// if let firstUnwatched = firstUnwatchedSearch {
|
||||||
self.playButtonItem = firstUnwatched
|
// self.playButtonItem = firstUnwatched
|
||||||
} else {
|
// } else {
|
||||||
guard let firstEpisode = self.episodes.first else { return }
|
// guard let firstEpisode = self.episodes.first else { return }
|
||||||
self.playButtonItem = firstEpisode
|
// self.playButtonItem = firstEpisode
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
})
|
})
|
||||||
.store(in: &cancellables)
|
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -11,16 +11,25 @@ import Defaults
|
|||||||
import Foundation
|
import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
final class SeriesItemViewModel: ItemViewModel {
|
final class SeriesItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var seasons: [BaseItemDto] = []
|
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
|
||||||
|
@Published
|
||||||
|
var selectedSeason: BaseItemDto?
|
||||||
|
|
||||||
override init(item: BaseItemDto) {
|
override init(item: BaseItemDto) {
|
||||||
super.init(item: item)
|
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()
|
getNextUp()
|
||||||
|
getResumeItem()
|
||||||
|
getFirstAvailableItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func playButtonText() -> String {
|
override func playButtonText() -> String {
|
||||||
@ -33,20 +42,16 @@ final class SeriesItemViewModel: ItemViewModel {
|
|||||||
return L10n.missing
|
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
|
return episodeLocator
|
||||||
}
|
}
|
||||||
|
|
||||||
override func shouldDisplayRuntime() -> Bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getNextUp() {
|
private func getNextUp() {
|
||||||
|
|
||||||
LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))")
|
LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))")
|
||||||
TvShowsAPI.getNextUp(
|
TvShowsAPI.getNextUp(
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
userId: SessionManager.main.currentLogin.user.id,
|
||||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
|
||||||
seriesId: self.item.id!,
|
seriesId: self.item.id!,
|
||||||
enableUserData: true
|
enableUserData: true
|
||||||
)
|
)
|
||||||
@ -56,12 +61,64 @@ final class SeriesItemViewModel: ItemViewModel {
|
|||||||
}, receiveValue: { [weak self] response in
|
}, receiveValue: { [weak self] response in
|
||||||
if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing {
|
if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing {
|
||||||
self?.playButtonItem = nextUpItem
|
self?.playButtonItem = nextUpItem
|
||||||
|
|
||||||
|
if let seasonID = nextUpItem.seasonId {
|
||||||
|
self?.select(seasonID: seasonID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.store(in: &cancellables)
|
.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()
|
let dateFormatter = DateFormatter()
|
||||||
dateFormatter.dateFormat = "yyyy"
|
dateFormatter.dateFormat = "yyyy"
|
||||||
|
|
||||||
@ -78,23 +135,4 @@ final class SeriesItemViewModel: ItemViewModel {
|
|||||||
|
|
||||||
return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)"
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -29,14 +29,7 @@ final class LatestMediaViewModel: ViewModel {
|
|||||||
UserLibraryAPI.getLatestMedia(
|
UserLibraryAPI.getLatestMedia(
|
||||||
userId: SessionManager.main.currentLogin.user.id,
|
userId: SessionManager.main.currentLogin.user.id,
|
||||||
parentId: library.id ?? "",
|
parentId: library.id ?? "",
|
||||||
fields: [
|
fields: ItemFields.allCases,
|
||||||
.primaryImageAspectRatio,
|
|
||||||
.seriesPrimaryImage,
|
|
||||||
.seasonUserData,
|
|
||||||
.overview,
|
|
||||||
.genres,
|
|
||||||
.people,
|
|
||||||
],
|
|
||||||
includeItemTypes: [.series, .movie],
|
includeItemTypes: [.series, .movie],
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
limit: 12
|
limit: 12
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
@ -14,8 +15,18 @@ final class LibraryListViewModel: ViewModel {
|
|||||||
@Published
|
@Published
|
||||||
var libraries: [BaseItemDto] = []
|
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
|
// temp
|
||||||
var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: [])
|
let withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: [])
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
@ -115,15 +115,7 @@ final class LibraryViewModel: ViewModel {
|
|||||||
searchTerm: nil,
|
searchTerm: nil,
|
||||||
sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) },
|
sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) },
|
||||||
parentId: parentID,
|
parentId: parentID,
|
||||||
fields: [
|
fields: ItemFields.allCases,
|
||||||
.primaryImageAspectRatio,
|
|
||||||
.seriesPrimaryImage,
|
|
||||||
.seasonUserData,
|
|
||||||
.overview,
|
|
||||||
.genres,
|
|
||||||
.people,
|
|
||||||
.chapters,
|
|
||||||
],
|
|
||||||
includeItemTypes: includeItemTypes,
|
includeItemTypes: includeItemTypes,
|
||||||
filters: filters.filters,
|
filters: filters.filters,
|
||||||
sortBy: sortBy,
|
sortBy: sortBy,
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -29,7 +29,7 @@ final class MovieLibrariesViewModel: ViewModel {
|
|||||||
private let columns: Int
|
private let columns: Int
|
||||||
|
|
||||||
@RouterObject
|
@RouterObject
|
||||||
var router: MovieLibrariesCoordinator.Router?
|
private var router: MovieLibrariesCoordinator.Router?
|
||||||
|
|
||||||
init(columns: Int = 7) {
|
init(columns: Int = 7) {
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
|
@ -29,7 +29,7 @@ final class TVLibrariesViewModel: ViewModel {
|
|||||||
private let columns: Int
|
private let columns: Int
|
||||||
|
|
||||||
@RouterObject
|
@RouterObject
|
||||||
var router: TVLibrariesCoordinator.Router?
|
private var router: TVLibrariesCoordinator.Router?
|
||||||
|
|
||||||
init(columns: Int = 7) {
|
init(columns: Int = 7) {
|
||||||
self.columns = columns
|
self.columns = columns
|
||||||
|
@ -14,7 +14,7 @@ import Stinsen
|
|||||||
final class UserSignInViewModel: ViewModel {
|
final class UserSignInViewModel: ViewModel {
|
||||||
|
|
||||||
@RouterObject
|
@RouterObject
|
||||||
var router: UserSignInCoordinator.Router?
|
private var Router: UserSignInCoordinator.Router?
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var publicUsers: [UserDto] = []
|
var publicUsers: [UserDto] = []
|
||||||
|
@ -123,6 +123,7 @@ final class VideoPlayerViewModel: ViewModel {
|
|||||||
let directStreamURL: URL
|
let directStreamURL: URL
|
||||||
let transcodedStreamURL: URL?
|
let transcodedStreamURL: URL?
|
||||||
let hlsStreamURL: URL
|
let hlsStreamURL: URL
|
||||||
|
let videoStream: MediaStream
|
||||||
let audioStreams: [MediaStream]
|
let audioStreams: [MediaStream]
|
||||||
let subtitleStreams: [MediaStream]
|
let subtitleStreams: [MediaStream]
|
||||||
let chapters: [ChapterInfo]
|
let chapters: [ChapterInfo]
|
||||||
@ -220,6 +221,7 @@ final class VideoPlayerViewModel: ViewModel {
|
|||||||
hlsStreamURL: URL,
|
hlsStreamURL: URL,
|
||||||
streamType: ServerStreamType,
|
streamType: ServerStreamType,
|
||||||
response: PlaybackInfoResponse,
|
response: PlaybackInfoResponse,
|
||||||
|
videoStream: MediaStream,
|
||||||
audioStreams: [MediaStream],
|
audioStreams: [MediaStream],
|
||||||
subtitleStreams: [MediaStream],
|
subtitleStreams: [MediaStream],
|
||||||
chapters: [ChapterInfo],
|
chapters: [ChapterInfo],
|
||||||
@ -243,6 +245,7 @@ final class VideoPlayerViewModel: ViewModel {
|
|||||||
self.hlsStreamURL = hlsStreamURL
|
self.hlsStreamURL = hlsStreamURL
|
||||||
self.streamType = streamType
|
self.streamType = streamType
|
||||||
self.response = response
|
self.response = response
|
||||||
|
self.videoStream = videoStream
|
||||||
self.audioStreams = audioStreams
|
self.audioStreams = audioStreams
|
||||||
self.subtitleStreams = subtitleStreams
|
self.subtitleStreams = subtitleStreams
|
||||||
self.chapters = chapters
|
self.chapters = chapters
|
||||||
@ -333,7 +336,7 @@ extension VideoPlayerViewModel {
|
|||||||
|
|
||||||
extension VideoPlayerViewModel {
|
extension VideoPlayerViewModel {
|
||||||
func getAdjacentEpisodes() {
|
func getAdjacentEpisodes() {
|
||||||
guard let seriesID = item.seriesId, item.itemType == .episode else { return }
|
guard let seriesID = item.seriesId, item.type == .episode else { return }
|
||||||
|
|
||||||
TvShowsAPI.getEpisodes(
|
TvShowsAPI.getEpisodes(
|
||||||
seriesId: seriesID,
|
seriesId: seriesID,
|
||||||
|
32
Shared/Views/AttributeFillView.swift
Normal file
32
Shared/Views/AttributeFillView.swift
Normal 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))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
Shared/Views/AttributeOutlineView.swift
Normal file
26
Shared/Views/AttributeOutlineView.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -11,10 +11,16 @@ import UIKit
|
|||||||
|
|
||||||
struct BlurHashView: UIViewRepresentable {
|
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 {
|
func makeUIView(context: Context) -> UIBlurHashView {
|
||||||
UIBlurHashView(blurHash)
|
UIBlurHashView(blurHash, size: size)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIBlurHashView, context: Context) {}
|
func updateUIView(_ uiView: UIBlurHashView, context: Context) {}
|
||||||
@ -24,14 +30,14 @@ class UIBlurHashView: UIView {
|
|||||||
|
|
||||||
private let imageView: UIImageView
|
private let imageView: UIImageView
|
||||||
|
|
||||||
init(_ blurHash: String) {
|
init(_ blurHash: String, size: CGSize) {
|
||||||
let imageView = UIImageView()
|
let imageView = UIImageView()
|
||||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
self.imageView = imageView
|
self.imageView = imageView
|
||||||
|
|
||||||
super.init(frame: .zero)
|
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 }
|
guard let self = self else { return }
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.imageView.image = blurImage
|
self.imageView.image = blurImage
|
||||||
@ -54,9 +60,9 @@ class UIBlurHashView: UIView {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
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 {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let image = UIImage(blurHash: blurHash, size: .Circle(radius: 12))
|
let image = UIImage(blurHash: blurHash, size: size)
|
||||||
completion(image)
|
completion(image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
29
Shared/Views/BlurView.swift
Normal file
29
Shared/Views/BlurView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -6,12 +6,13 @@
|
|||||||
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct LazyView<Content: View>: View {
|
struct Divider: View {
|
||||||
var content: () -> Content
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
self.content()
|
Color.secondarySystemFill
|
||||||
|
.frame(height: 0.5)
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -11,9 +11,7 @@ import NukeUI
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// TODO: Fix 100+ inits
|
struct ImageSource {
|
||||||
|
|
||||||
struct ImageViewSource {
|
|
||||||
let url: URL?
|
let url: URL?
|
||||||
let blurHash: String?
|
let blurHash: String?
|
||||||
|
|
||||||
@ -33,25 +31,38 @@ struct DefaultFailureView: View {
|
|||||||
struct ImageView<FailureView: View>: View {
|
struct ImageView<FailureView: View>: View {
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var sources: [ImageViewSource]
|
private var sources: [ImageSource]
|
||||||
private var currentURL: URL? { sources.first?.url }
|
private var currentURL: URL? { sources.first?.url }
|
||||||
private var currentBlurHash: String? { sources.first?.blurHash }
|
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) {
|
init(
|
||||||
let imageViewSource = ImageViewSource(url: source, blurHash: blurHash)
|
_ source: URL?,
|
||||||
_sources = State(initialValue: [imageViewSource])
|
blurHash: String? = nil,
|
||||||
self.failureView = failureView()
|
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) {
|
init(
|
||||||
_sources = State(initialValue: [source])
|
_ source: ImageSource,
|
||||||
self.failureView = failureView()
|
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)
|
_sources = State(initialValue: sources)
|
||||||
self.failureView = failureView()
|
self.resizingMode = resizingMode
|
||||||
|
self.failureView = failureView
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -60,18 +71,20 @@ struct ImageView<FailureView: View>: View {
|
|||||||
BlurHashView(blurHash: currentBlurHash)
|
BlurHashView(blurHash: currentBlurHash)
|
||||||
.id(currentBlurHash)
|
.id(currentBlurHash)
|
||||||
} else {
|
} else {
|
||||||
Color.secondary
|
Color.clear
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
||||||
if let currentURL = currentURL {
|
if let currentURL = currentURL {
|
||||||
LazyImage(source: currentURL) { state in
|
LazyImage(source: currentURL) { state in
|
||||||
if let image = state.image {
|
if let image = state.image {
|
||||||
image
|
image
|
||||||
|
.resizingMode(resizingMode)
|
||||||
} else if state.error != nil {
|
} else if state.error != nil {
|
||||||
placeholderView.onAppear { sources.removeFirst() }
|
placeholderView.onAppear {
|
||||||
|
sources.removeFirst()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
placeholderView
|
placeholderView
|
||||||
}
|
}
|
||||||
@ -79,27 +92,27 @@ struct ImageView<FailureView: View>: View {
|
|||||||
.pipeline(ImagePipeline(configuration: .withDataCache))
|
.pipeline(ImagePipeline(configuration: .withDataCache))
|
||||||
.id(currentURL)
|
.id(currentURL)
|
||||||
} else {
|
} else {
|
||||||
failureView
|
failureView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ImageView where FailureView == DefaultFailureView {
|
extension ImageView where FailureView == DefaultFailureView {
|
||||||
init(_ source: URL?, blurHash: String? = nil) {
|
init(_ source: URL?, blurHash: String? = nil, resizingMode: ImageResizingMode = .aspectFill) {
|
||||||
let imageViewSource = ImageViewSource(url: source, blurHash: blurHash)
|
let imageSource = ImageSource(url: source, blurHash: blurHash)
|
||||||
self.init(imageViewSource, failureView: { DefaultFailureView() })
|
self.init([imageSource], resizingMode: resizingMode, failureView: { DefaultFailureView() })
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ source: ImageViewSource) {
|
init(_ source: ImageSource, resizingMode: ImageResizingMode = .aspectFill) {
|
||||||
self.init(source, failureView: { DefaultFailureView() })
|
self.init([source], resizingMode: resizingMode, failureView: { DefaultFailureView() })
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ sources: [ImageViewSource]) {
|
init(_ sources: [ImageSource], resizingMode: ImageResizingMode = .aspectFill) {
|
||||||
self.init(sources, failureView: { DefaultFailureView() })
|
self.init(sources, resizingMode: resizingMode, failureView: { DefaultFailureView() })
|
||||||
}
|
}
|
||||||
|
|
||||||
init(sources: [URL]) {
|
init(sources: [URL], resizingMode: ImageResizingMode = .aspectFill) {
|
||||||
let imageViewSources = sources.compactMap { ImageViewSource(url: $0, blurHash: nil) }
|
let imageSources = sources.compactMap { ImageSource(url: $0, blurHash: nil) }
|
||||||
self.init(imageViewSources, failureView: { DefaultFailureView() })
|
self.init(imageSources, resizingMode: resizingMode, failureView: { DefaultFailureView() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
146
Shared/Views/TruncatedTextView.swift
Normal file
146
Shared/Views/TruncatedTextView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
234
Swiftfin tvOS/Components/DotHStack.swift
Normal file
234
Swiftfin tvOS/Components/DotHStack.swift
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -28,10 +28,10 @@ class DynamicCinematicBackgroundViewModel: ObservableObject {
|
|||||||
|
|
||||||
let backdropImage: URL
|
let backdropImage: URL
|
||||||
|
|
||||||
if item.itemType == .episode {
|
if item.type == .episode {
|
||||||
backdropImage = item.getSeriesBackdropImage(maxWidth: 1920)
|
backdropImage = item.seriesImageURL(.backdrop, maxWidth: 1920)
|
||||||
} else {
|
} else {
|
||||||
backdropImage = item.getBackdropImage(maxWidth: 1920)
|
backdropImage = item.imageURL(.backdrop, maxWidth: 1920)
|
||||||
}
|
}
|
||||||
|
|
||||||
let options = ImageLoadingOptions(transition: .fadeIn(duration: 0.2))
|
let options = ImageLoadingOptions(transition: .fadeIn(duration: 0.2))
|
||||||
|
@ -12,27 +12,27 @@ import SwiftUI
|
|||||||
struct CinematicNextUpCardView: View {
|
struct CinematicNextUpCardView: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
var homeRouter: HomeCoordinator.Router
|
private var homeRouter: HomeCoordinator.Router
|
||||||
let item: BaseItemDto
|
let item: BaseItemDto
|
||||||
let showOverlay: Bool
|
let showOverlay: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Button {
|
Button {
|
||||||
homeRouter.route(to: \.modalItem, item)
|
homeRouter.route(to: \.item, item)
|
||||||
} label: {
|
} label: {
|
||||||
ZStack(alignment: .bottomLeading) {
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
|
||||||
if item.itemType == .episode {
|
if item.type == .episode {
|
||||||
ImageView(sources: [
|
ImageView([
|
||||||
item.getSeriesThumbImage(maxWidth: 350),
|
item.seriesImageSource(.thumb, maxWidth: 350),
|
||||||
item.getSeriesBackdropImage(maxWidth: 350),
|
item.seriesImageSource(.backdrop, maxWidth: 350),
|
||||||
])
|
])
|
||||||
.frame(width: 350, height: 210)
|
.frame(width: 350, height: 210)
|
||||||
} else {
|
} else {
|
||||||
ImageView([
|
ImageView([
|
||||||
.init(url: item.getThumbImage(maxWidth: 350)),
|
item.imageSource(.thumb, maxWidth: 350),
|
||||||
.init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()),
|
item.imageSource(.backdrop, maxWidth: 350),
|
||||||
])
|
])
|
||||||
.frame(width: 350, height: 210)
|
.frame(width: 350, height: 210)
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ import SwiftUI
|
|||||||
struct CinematicResumeCardView: View {
|
struct CinematicResumeCardView: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
var homeRouter: HomeCoordinator.Router
|
private var homeRouter: HomeCoordinator.Router
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var viewModel: HomeViewModel
|
var viewModel: HomeViewModel
|
||||||
let item: BaseItemDto
|
let item: BaseItemDto
|
||||||
@ -20,20 +20,20 @@ struct CinematicResumeCardView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Button {
|
Button {
|
||||||
homeRouter.route(to: \.modalItem, item)
|
homeRouter.route(to: \.item, item)
|
||||||
} label: {
|
} label: {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
|
|
||||||
if item.itemType == .episode {
|
if item.type == .episode {
|
||||||
ImageView(sources: [
|
ImageView([
|
||||||
item.getSeriesThumbImage(maxWidth: 350),
|
item.seriesImageSource(.thumb, maxWidth: 350),
|
||||||
item.getSeriesBackdropImage(maxWidth: 350),
|
item.seriesImageSource(.backdrop, maxWidth: 350),
|
||||||
])
|
])
|
||||||
.frame(width: 350, height: 210)
|
.frame(width: 350, height: 210)
|
||||||
} else {
|
} else {
|
||||||
ImageView([
|
ImageView([
|
||||||
.init(url: item.getThumbImage(maxWidth: 350)),
|
item.imageSource(.thumb, maxWidth: 350),
|
||||||
.init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()),
|
item.imageSource(.backdrop, maxWidth: 350),
|
||||||
])
|
])
|
||||||
.frame(width: 350, height: 210)
|
.frame(width: 350, height: 210)
|
||||||
}
|
}
|
||||||
@ -54,7 +54,7 @@ struct CinematicResumeCardView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Color(UIColor.systemPurple)
|
Color.jellyfinPurple
|
||||||
.frame(width: 350 * (item.userData?.playedPercentage ?? 0) / 100, height: 7)
|
.frame(width: 350 * (item.userData?.playedPercentage ?? 0) / 100, height: 7)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
@ -54,7 +54,7 @@ struct HomeCinematicView: View {
|
|||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
|
|
||||||
CinematicBackgroundView(viewModel: backgroundViewModel)
|
CinematicBackgroundView(viewModel: backgroundViewModel)
|
||||||
.frame(height: UIScreen.main.bounds.height - 10)
|
.frame(height: UIScreen.main.bounds.height - 50)
|
||||||
|
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
stops: [
|
stops: [
|
||||||
@ -77,8 +77,8 @@ struct HomeCinematicView: View {
|
|||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(Color.secondary)
|
.foregroundColor(Color.secondary)
|
||||||
} else {
|
} else {
|
||||||
if updatedSelectedItem?.itemType == .episode {
|
if updatedSelectedItem?.type == .episode {
|
||||||
Text(updatedSelectedItem?.getEpisodeLocator() ?? "")
|
Text(updatedSelectedItem?.episodeLocator ?? "")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(Color.secondary)
|
.foregroundColor(Color.secondary)
|
||||||
|
@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: Replace and remove
|
||||||
|
|
||||||
struct ItemDetailsView: View {
|
struct ItemDetailsView: View {
|
||||||
|
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
@ -28,9 +30,9 @@ struct ItemDetailsView: View {
|
|||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(.bottom, 5)
|
.padding(.bottom, 5)
|
||||||
|
|
||||||
ForEach(viewModel.informationItems, id: \.self.title) { informationItem in
|
// ForEach(viewModel.informationItems, id: \.self.title) { informationItem in
|
||||||
ItemDetail(title: informationItem.title, content: informationItem.content)
|
// ItemDetail(title: informationItem.title, content: informationItem.content)
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
@ -54,65 +54,61 @@ struct LandscapeItemElement: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
ImageView(
|
ImageView(item.imageSource(.backdrop, maxWidth: 445))
|
||||||
item.type == .episode && !(inSeasonView ?? false) ? item.getSeriesBackdropImage(maxWidth: 445) : item
|
.frame(width: 445, height: 250)
|
||||||
.getBackdropImage(maxWidth: 445),
|
.cornerRadius(10)
|
||||||
blurHash: item.type == .episode ? item.getSeriesBackdropImageBlurHash() : item.getBackdropImageBlurHash()
|
.ignoresSafeArea()
|
||||||
)
|
.overlay(
|
||||||
.frame(width: 445, height: 250)
|
ZStack {
|
||||||
.cornerRadius(10)
|
if item.userData?.played ?? false {
|
||||||
.ignoresSafeArea()
|
Image(systemName: "circle.fill")
|
||||||
.overlay(
|
.foregroundColor(.white)
|
||||||
ZStack {
|
Image(systemName: "checkmark.circle.fill")
|
||||||
if item.userData?.played ?? false {
|
.foregroundColor(Color(.systemBlue))
|
||||||
Image(systemName: "circle.fill")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(Color(.systemBlue))
|
|
||||||
}
|
|
||||||
}.padding(2)
|
|
||||||
.opacity(1),
|
|
||||||
alignment: .topTrailing
|
|
||||||
).opacity(1)
|
|
||||||
.overlay(ZStack(alignment: .leading) {
|
|
||||||
if focused && item.userData?.playedPercentage != nil {
|
|
||||||
Rectangle()
|
|
||||||
.fill(LinearGradient(
|
|
||||||
gradient: Gradient(colors: [.black, .clear]),
|
|
||||||
startPoint: .bottom,
|
|
||||||
endPoint: .top
|
|
||||||
))
|
|
||||||
.frame(width: 445, height: 90)
|
|
||||||
.mask(CutOffShadow())
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text("CONTINUE • \(item.getItemProgressString() ?? "")")
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.offset(y: 5)
|
|
||||||
ZStack(alignment: .leading) {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(Color.gray)
|
|
||||||
.opacity(0.4)
|
|
||||||
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12)
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.fill(Color.jellyfinPurple)
|
|
||||||
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12)
|
|
||||||
}
|
}
|
||||||
}.padding(12)
|
}.padding(2)
|
||||||
} else {
|
.opacity(1),
|
||||||
EmptyView()
|
alignment: .topTrailing
|
||||||
}
|
).opacity(1)
|
||||||
}, alignment: .bottomLeading)
|
.overlay(ZStack(alignment: .leading) {
|
||||||
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
if focused && item.userData?.playedPercentage != nil {
|
||||||
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
Rectangle()
|
||||||
|
.fill(LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.black, .clear]),
|
||||||
|
startPoint: .bottom,
|
||||||
|
endPoint: .top
|
||||||
|
))
|
||||||
|
.frame(width: 445, height: 90)
|
||||||
|
.mask(CutOffShadow())
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("CONTINUE • \(item.getItemProgressString() ?? "")")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.offset(y: 5)
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.gray)
|
||||||
|
.opacity(0.4)
|
||||||
|
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12)
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color.jellyfinPurple)
|
||||||
|
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12)
|
||||||
|
}
|
||||||
|
}.padding(12)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}, alignment: .bottomLeading)
|
||||||
|
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
||||||
|
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
||||||
if inSeasonView ?? false {
|
if inSeasonView ?? false {
|
||||||
Text("\(item.getEpisodeLocator() ?? "") • \(item.name ?? "")")
|
Text("\(item.episodeLocator ?? "") • \(item.name ?? "")")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(width: 445)
|
.frame(width: 445)
|
||||||
} else {
|
} else {
|
||||||
Text(item.type == .episode ? "\(item.seriesName ?? "") • \(item.getEpisodeLocator() ?? "")" : item.name ?? "")
|
Text(item.type == .episode ? "\(item.seriesName ?? "") • \(item.episodeLocator ?? "")" : item.name ?? "")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -123,16 +119,6 @@ struct LandscapeItemElement: View {
|
|||||||
withAnimation(.linear(duration: 0.15)) {
|
withAnimation(.linear(duration: 0.15)) {
|
||||||
self.focused = envFocus
|
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)
|
.scaleEffect(focused ? 1.1 : 1)
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
58
Swiftfin tvOS/Components/PortraitButton.swift
Normal file
58
Swiftfin tvOS/Components/PortraitButton.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
@ -9,7 +9,9 @@
|
|||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: Transition to `PortraitButton`
|
||||||
struct PortraitItemElement: View {
|
struct PortraitItemElement: View {
|
||||||
|
|
||||||
@Environment(\.isFocused)
|
@Environment(\.isFocused)
|
||||||
var envFocused: Bool
|
var envFocused: Bool
|
||||||
@State
|
@State
|
||||||
@ -21,49 +23,46 @@ struct PortraitItemElement: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
ImageView(
|
ImageView(item.type == .episode ? item.seriesImageSource(.primary, maxWidth: 200) : item.imageSource(.primary, maxWidth: 200))
|
||||||
item.type == .episode ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200),
|
.frame(width: 200, height: 300)
|
||||||
blurHash: item.type == .episode ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()
|
.cornerRadius(10)
|
||||||
)
|
.shadow(radius: focused ? 10.0 : 0)
|
||||||
.frame(width: 200, height: 300)
|
.shadow(radius: focused ? 10.0 : 0)
|
||||||
.cornerRadius(10)
|
.overlay(
|
||||||
.shadow(radius: focused ? 10.0 : 0)
|
ZStack {
|
||||||
.shadow(radius: focused ? 10.0 : 0)
|
if item.userData?.isFavorite ?? false {
|
||||||
.overlay(
|
|
||||||
ZStack {
|
|
||||||
if item.userData?.isFavorite ?? false {
|
|
||||||
Image(systemName: "circle.fill")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.opacity(0.6)
|
|
||||||
Image(systemName: "heart.fill")
|
|
||||||
.foregroundColor(Color(.systemRed))
|
|
||||||
.font(.system(size: 10))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(2)
|
|
||||||
.opacity(1),
|
|
||||||
alignment: .bottomLeading
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
ZStack {
|
|
||||||
if item.userData?.played ?? false {
|
|
||||||
Image(systemName: "circle.fill")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.foregroundColor(Color(.systemBlue))
|
|
||||||
} else {
|
|
||||||
if item.userData?.unplayedItemCount != nil {
|
|
||||||
Image(systemName: "circle.fill")
|
Image(systemName: "circle.fill")
|
||||||
.foregroundColor(Color(.systemBlue))
|
|
||||||
Text(String(item.userData!.unplayedItemCount ?? 0))
|
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.font(.caption2)
|
.opacity(0.6)
|
||||||
|
Image(systemName: "heart.fill")
|
||||||
|
.foregroundColor(Color(.systemRed))
|
||||||
|
.font(.system(size: 10))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.padding(2)
|
.padding(2)
|
||||||
.opacity(1),
|
.opacity(1),
|
||||||
alignment: .topTrailing
|
alignment: .bottomLeading
|
||||||
).opacity(1)
|
)
|
||||||
|
.overlay(
|
||||||
|
ZStack {
|
||||||
|
if item.userData?.played ?? false {
|
||||||
|
Image(systemName: "circle.fill")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(Color(.systemBlue))
|
||||||
|
} else {
|
||||||
|
if item.userData?.unplayedItemCount != nil {
|
||||||
|
Image(systemName: "circle.fill")
|
||||||
|
.foregroundColor(Color(.systemBlue))
|
||||||
|
Text(String(item.userData!.unplayedItemCount ?? 0))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.padding(2)
|
||||||
|
.opacity(1),
|
||||||
|
alignment: .topTrailing
|
||||||
|
).opacity(1)
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.frame(width: 200, height: 30, alignment: .center)
|
.frame(width: 200, height: 30, alignment: .center)
|
||||||
if item.type == .movie || item.type == .series {
|
if item.type == .movie || item.type == .series {
|
||||||
@ -87,16 +86,6 @@ struct PortraitItemElement: View {
|
|||||||
withAnimation(.linear(duration: 0.15)) {
|
withAnimation(.linear(duration: 0.15)) {
|
||||||
self.focused = envFocus
|
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)
|
.scaleEffect(focused ? 1.1 : 1)
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
89
Swiftfin tvOS/Components/PortraitPosterHStack.swift
Normal file
89
Swiftfin tvOS/Components/PortraitPosterHStack.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
149
Swiftfin tvOS/Objects/FocusGuide.swift
Normal file
149
Swiftfin tvOS/Objects/FocusGuide.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AboutView: View {
|
struct AboutAppView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("dud")
|
Text("dud")
|
@ -13,7 +13,7 @@ import SwiftUI
|
|||||||
struct BasicAppSettingsView: View {
|
struct BasicAppSettingsView: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router
|
private var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var viewModel: BasicAppSettingsViewModel
|
var viewModel: BasicAppSettingsViewModel
|
||||||
@State
|
@State
|
||||||
|
@ -12,21 +12,24 @@ import SwiftUI
|
|||||||
struct ContinueWatchingCard: View {
|
struct ContinueWatchingCard: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
var homeRouter: HomeCoordinator.Router
|
private var homeRouter: HomeCoordinator.Router
|
||||||
let item: BaseItemDto
|
let item: BaseItemDto
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Button {
|
Button {
|
||||||
homeRouter.route(to: \.modalItem, item)
|
homeRouter.route(to: \.item, item)
|
||||||
} label: {
|
} label: {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
|
|
||||||
if item.itemType == .episode {
|
if item.type == .episode {
|
||||||
ImageView(item.getSeriesBackdropImage(maxWidth: 500))
|
ImageView([
|
||||||
.frame(width: 500, height: 281.25)
|
item.seriesImageSource(.thumb, maxWidth: 500),
|
||||||
|
item.imageSource(.primary, maxWidth: 500),
|
||||||
|
])
|
||||||
|
.frame(width: 500, height: 281.25)
|
||||||
} else {
|
} else {
|
||||||
ImageView(item.getBackdropImage(maxWidth: 500))
|
ImageView(item.imageURL(.backdrop, maxWidth: 500))
|
||||||
.frame(width: 500, height: 281.25)
|
.frame(width: 500, height: 281.25)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,8 +69,8 @@ struct ContinueWatchingCard: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(width: 500, alignment: .leading)
|
.frame(width: 500, alignment: .leading)
|
||||||
|
|
||||||
if item.itemType == .episode {
|
if item.type == .episode {
|
||||||
Text(item.getEpisodeLocator() ?? "")
|
Text(item.episodeLocator ?? "--")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
@ -14,7 +14,7 @@ import SwiftUI
|
|||||||
struct ContinueWatchingView: View {
|
struct ContinueWatchingView: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
var homeRouter: HomeCoordinator.Router
|
private var homeRouter: HomeCoordinator.Router
|
||||||
let items: [BaseItemDto]
|
let items: [BaseItemDto]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@ -8,20 +8,16 @@
|
|||||||
|
|
||||||
import Defaults
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Introspect
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
var homeRouter: HomeCoordinator.Router
|
private var router: HomeCoordinator.Router
|
||||||
@StateObject
|
@ObservedObject
|
||||||
var viewModel = HomeViewModel()
|
var viewModel: HomeViewModel
|
||||||
@Default(.showPosterLabels)
|
|
||||||
var showPosterLabels
|
|
||||||
|
|
||||||
@State
|
|
||||||
var showingSettings = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
@ -39,8 +35,12 @@ struct HomeView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if !viewModel.nextUpItems.isEmpty {
|
if !viewModel.nextUpItems.isEmpty {
|
||||||
NextUpView(items: viewModel.nextUpItems)
|
PortraitPosterHStack(
|
||||||
.focusSection()
|
title: L10n.nextUp,
|
||||||
|
items: viewModel.nextUpItems
|
||||||
|
) { item in
|
||||||
|
router.route(to: \.item, item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HomeCinematicView(
|
HomeCinematicView(
|
||||||
@ -49,38 +49,27 @@ struct HomeView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if !viewModel.nextUpItems.isEmpty {
|
if !viewModel.nextUpItems.isEmpty {
|
||||||
NextUpView(items: viewModel.nextUpItems)
|
PortraitPosterHStack(
|
||||||
.focusSection()
|
title: L10n.nextUp,
|
||||||
|
items: viewModel.nextUpItems
|
||||||
|
) { item in
|
||||||
|
router.route(to: \.item, item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PortraitItemsRowView(
|
if !viewModel.latestAddedItems.isEmpty {
|
||||||
rowTitle: L10n.recentlyAdded,
|
PortraitPosterHStack(
|
||||||
items: viewModel.latestAddedItems,
|
title: L10n.recentlyAdded,
|
||||||
showItemTitles: showPosterLabels
|
items: viewModel.latestAddedItems
|
||||||
) { item in
|
) { item in
|
||||||
homeRouter.route(to: \.modalItem, item)
|
router.route(to: \.item, item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(viewModel.libraries, id: \.self) { library in
|
ForEach(viewModel.libraries, id: \.self) { library in
|
||||||
LatestMediaView(viewModel: LatestMediaViewModel(library: library))
|
LatestInLibraryView(viewModel: LatestMediaViewModel(library: library))
|
||||||
.focusSection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 100)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.refresh()
|
|
||||||
} label: {
|
|
||||||
L10n.refresh.text
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.focusSection()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.edgesIgnoringSafeArea(.top)
|
.edgesIgnoringSafeArea(.top)
|
||||||
|
@ -46,28 +46,24 @@ struct CinematicCollectionItemView: View {
|
|||||||
|
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
// VStack(alignment: .leading, spacing: 20) {
|
||||||
|
//
|
||||||
CinematicItemAboutView(viewModel: viewModel)
|
// CinematicItemAboutView(viewModel: viewModel)
|
||||||
|
//
|
||||||
PortraitItemsRowView(
|
// PortraitImageHStack(rowTitle: L10n.items,
|
||||||
rowTitle: L10n.items,
|
// items: viewModel.collectionItems) { item in
|
||||||
items: viewModel.collectionItems
|
// itemRouter.route(to: \.item, item)
|
||||||
) { item in
|
// }
|
||||||
itemRouter.route(to: \.item, item)
|
//
|
||||||
}
|
// if !viewModel.similarItems.isEmpty {
|
||||||
|
// PortraitImageHStack(rowTitle: L10n.recommended,
|
||||||
if !viewModel.similarItems.isEmpty {
|
// items: viewModel.similarItems,
|
||||||
PortraitItemsRowView(
|
// showItemTitles: showPosterLabels) { item in
|
||||||
rowTitle: L10n.recommended,
|
// itemRouter.route(to: \.item, item)
|
||||||
items: viewModel.similarItems,
|
// }
|
||||||
showItemTitles: showPosterLabels
|
// }
|
||||||
) { item in
|
// }
|
||||||
itemRouter.route(to: \.item, item)
|
// .padding(.vertical, 50)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 50)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -22,7 +22,7 @@ struct CinematicEpisodeItemView: View {
|
|||||||
var showPosterLabels
|
var showPosterLabels
|
||||||
|
|
||||||
func generateSubtitle() -> String? {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,27 +60,23 @@ struct CinematicEpisodeItemView: View {
|
|||||||
|
|
||||||
CinematicItemAboutView(viewModel: viewModel)
|
CinematicItemAboutView(viewModel: viewModel)
|
||||||
|
|
||||||
EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true)
|
// EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true)
|
||||||
.focusSection()
|
// .focusSection()
|
||||||
|
|
||||||
if let seriesItem = viewModel.series {
|
// if let seriesItem = viewModel.series {
|
||||||
PortraitItemsRowView(
|
// PortraitItemsRowView(rowTitle: L10n.series,
|
||||||
rowTitle: L10n.series,
|
// items: [seriesItem]) { seriesItem in
|
||||||
items: [seriesItem]
|
// itemRouter.route(to: \.item, seriesItem)
|
||||||
) { seriesItem in
|
// }
|
||||||
itemRouter.route(to: \.item, seriesItem)
|
// }
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !viewModel.similarItems.isEmpty {
|
// if !viewModel.similarItems.isEmpty {
|
||||||
PortraitItemsRowView(
|
// PortraitImageHStack(rowTitle: L10n.recommended,
|
||||||
rowTitle: L10n.recommended,
|
// items: viewModel.similarItems,
|
||||||
items: viewModel.similarItems,
|
// showItemTitles: showPosterLabels) { item in
|
||||||
showItemTitles: showPosterLabels
|
// itemRouter.route(to: \.item, item)
|
||||||
) { item in
|
// }
|
||||||
itemRouter.route(to: \.item, item)
|
// }
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ItemDetailsView(viewModel: viewModel)
|
ItemDetailsView(viewModel: viewModel)
|
||||||
}
|
}
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
Loading…
Reference in New Issue
Block a user