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, \
|
||||
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"
|
||||
|
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
|
||||
func makeAbout() -> some View {
|
||||
AboutView()
|
||||
AboutAppView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -19,14 +19,18 @@ final class HomeCoordinator: NavigationCoordinatable {
|
||||
var start = makeStart
|
||||
@Route(.modal)
|
||||
var settings = makeSettings
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.modal)
|
||||
var modalItem = makeModalItem
|
||||
@Route(.modal)
|
||||
var modalLibrary = makeModalLibrary
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.modal)
|
||||
var item = makeModalItem
|
||||
@Route(.modal)
|
||||
var library = makeModalLibrary
|
||||
#else
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#endif
|
||||
|
||||
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
|
||||
NavigationViewCoordinator(SettingsCoordinator())
|
||||
@ -50,6 +54,6 @@ final class HomeCoordinator: NavigationCoordinatable {
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
HomeView()
|
||||
HomeView(viewModel: .init())
|
||||
}
|
||||
}
|
||||
|
@ -44,12 +44,16 @@ final class ItemCoordinator: NavigationCoordinatable {
|
||||
NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto))
|
||||
}
|
||||
|
||||
func makeSeason(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
||||
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
ItemNavigationView(item: itemDto)
|
||||
ItemView(item: itemDto)
|
||||
}
|
||||
}
|
||||
|
@ -23,10 +23,14 @@ final class LibraryCoordinator: NavigationCoordinatable {
|
||||
var search = makeSearch
|
||||
@Route(.modal)
|
||||
var filter = makeFilter
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.modal)
|
||||
var modalItem = makeModalItem
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.modal)
|
||||
var item = makeModalItem
|
||||
#else
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
#endif
|
||||
|
||||
let viewModel: LibraryViewModel
|
||||
let title: String
|
||||
|
@ -63,7 +63,7 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||
|
||||
@ViewBuilder
|
||||
func makeAbout() -> some View {
|
||||
AboutView()
|
||||
AboutAppView()
|
||||
}
|
||||
|
||||
#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
|
||||
static let systemFill = Color(UIColor.systemFill)
|
||||
static let systemBackground = Color(UIColor.systemBackground)
|
||||
static let secondarySystemFill = Color(UIColor.secondarySystemBackground)
|
||||
static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground)
|
||||
static let secondarySystemFill = Color(UIColor.secondarySystemFill)
|
||||
static let tertiarySystemFill = Color(UIColor.tertiarySystemFill)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
0
Shared/Extensions/Defaults+Workaround.swift
Normal file → Executable file
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
|
||||
|
||||
// TODO: other forms of media subtitle
|
||||
if self.itemType == .episode {
|
||||
if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() {
|
||||
if self.type == .episode {
|
||||
if let seriesName = self.seriesName, let episodeLocator = self.episodeLocator {
|
||||
subtitle = "\(seriesName) - \(episodeLocator)"
|
||||
}
|
||||
}
|
||||
|
||||
let subtitlesEnabled = defaultSubtitleStream != nil
|
||||
|
||||
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
|
||||
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && type == .episode
|
||||
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
|
||||
|
||||
let overlayType = Defaults[.overlayType]
|
||||
|
||||
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode
|
||||
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode
|
||||
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && type == .episode
|
||||
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && type == .episode
|
||||
|
||||
var fileName: String?
|
||||
if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
|
||||
@ -155,6 +155,7 @@ extension BaseItemDto {
|
||||
hlsStreamURL: hlsStreamURL,
|
||||
streamType: streamType,
|
||||
response: response,
|
||||
videoStream: videoStream!,
|
||||
audioStreams: audioStreams,
|
||||
subtitleStreams: subtitleStreams,
|
||||
chapters: modifiedSelfItem.chapters ?? [],
|
||||
@ -292,21 +293,21 @@ extension BaseItemDto {
|
||||
modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
|
||||
|
||||
// TODO: other forms of media subtitle
|
||||
if self.itemType == .episode {
|
||||
if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() {
|
||||
if self.type == .episode {
|
||||
if let seriesName = self.seriesName, let episodeLocator = self.episodeLocator {
|
||||
subtitle = "\(seriesName) - \(episodeLocator)"
|
||||
}
|
||||
}
|
||||
|
||||
let subtitlesEnabled = defaultSubtitleStream != nil
|
||||
|
||||
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
|
||||
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && type == .episode
|
||||
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
|
||||
|
||||
let overlayType = Defaults[.overlayType]
|
||||
|
||||
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode
|
||||
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode
|
||||
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && type == .episode
|
||||
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && type == .episode
|
||||
|
||||
var fileName: String?
|
||||
if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
|
||||
@ -322,6 +323,7 @@ extension BaseItemDto {
|
||||
hlsStreamURL: hlsStreamURL,
|
||||
streamType: streamType,
|
||||
response: response,
|
||||
videoStream: videoStream!,
|
||||
audioStreams: audioStreams,
|
||||
subtitleStreams: subtitleStreams,
|
||||
chapters: modifiedSelfItem.chapters ?? [],
|
||||
|
@ -10,175 +10,22 @@ import Foundation
|
||||
import JellyfinAPI
|
||||
import UIKit
|
||||
|
||||
// 001fC^ = dark grey plain blurhash
|
||||
extension BaseItemDto: Identifiable {}
|
||||
|
||||
public extension BaseItemDto {
|
||||
// MARK: Images
|
||||
extension BaseItemDto {
|
||||
|
||||
func getSeriesBackdropImageBlurHash() -> String {
|
||||
let imgURL = getSeriesBackdropImage(maxWidth: 1)
|
||||
guard let imgTag = imgURL.queryParameters?["tag"],
|
||||
let hash = imageBlurHashes?.backdrop?[imgTag]
|
||||
else {
|
||||
return "001fC^"
|
||||
}
|
||||
|
||||
return hash
|
||||
var episodeLocator: String? {
|
||||
guard let episodeNo = indexNumber else { return nil }
|
||||
return L10n.episodeNumber(episodeNo)
|
||||
}
|
||||
|
||||
func getSeriesPrimaryImageBlurHash() -> String {
|
||||
let imgURL = getSeriesPrimaryImage(maxWidth: 1)
|
||||
guard let imgTag = imgURL.queryParameters?["tag"],
|
||||
let hash = imageBlurHashes?.primary?[imgTag]
|
||||
else {
|
||||
return "001fC^"
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
func getPrimaryImageBlurHash() -> String {
|
||||
let imgURL = getPrimaryImage(maxWidth: 1)
|
||||
guard let imgTag = imgURL.queryParameters?["tag"],
|
||||
let hash = imageBlurHashes?.primary?[imgTag]
|
||||
else {
|
||||
return "001fC^"
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
func getBackdropImageBlurHash() -> String {
|
||||
let imgURL = getBackdropImage(maxWidth: 1)
|
||||
guard let imgTag = imgURL.queryParameters?["tag"] else {
|
||||
return "001fC^"
|
||||
}
|
||||
|
||||
if imgURL.queryParameters?[ImageType.backdrop.rawValue] == nil {
|
||||
if itemType == .episode {
|
||||
return imageBlurHashes?.backdrop?.values.first ?? "001fC^"
|
||||
} else {
|
||||
return imageBlurHashes?.backdrop?[imgTag] ?? "001fC^"
|
||||
}
|
||||
} else {
|
||||
return imageBlurHashes?.primary?[imgTag] ?? "001fC^"
|
||||
}
|
||||
}
|
||||
|
||||
func getBackdropImage(maxWidth: Int) -> URL {
|
||||
var imageType = ImageType.backdrop
|
||||
var imageTag: String?
|
||||
var imageItemId = id ?? ""
|
||||
|
||||
if primaryImageAspectRatio ?? 0.0 < 1.0 {
|
||||
if !(backdropImageTags?.isEmpty ?? true) {
|
||||
imageTag = backdropImageTags?.first
|
||||
}
|
||||
} else {
|
||||
imageType = .primary
|
||||
imageTag = imageTags?[ImageType.primary.rawValue] ?? ""
|
||||
}
|
||||
|
||||
if imageTag == nil || imageItemId.isEmpty {
|
||||
if !(parentBackdropImageTags?.isEmpty ?? true) {
|
||||
imageTag = parentBackdropImageTags?.first
|
||||
imageItemId = parentBackdropItemId ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
|
||||
let urlString = ImageAPI.getItemImageWithRequestBuilder(
|
||||
itemId: imageItemId,
|
||||
imageType: imageType,
|
||||
maxWidth: Int(x),
|
||||
quality: 96,
|
||||
tag: imageTag
|
||||
).URLString
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
func getThumbImage(maxWidth: Int) -> URL {
|
||||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
|
||||
let urlString = ImageAPI.getItemImageWithRequestBuilder(
|
||||
itemId: id ?? "",
|
||||
imageType: .thumb,
|
||||
maxWidth: Int(x),
|
||||
quality: 96
|
||||
).URLString
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
func getEpisodeLocator() -> String? {
|
||||
var seasonEpisodeLocator: String? {
|
||||
if let seasonNo = parentIndexNumber, let episodeNo = indexNumber {
|
||||
return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSeriesBackdropImage(maxWidth: Int) -> URL {
|
||||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
let urlString = ImageAPI.getItemImageWithRequestBuilder(
|
||||
itemId: parentBackdropItemId ?? "",
|
||||
imageType: .backdrop,
|
||||
maxWidth: Int(x),
|
||||
quality: 96,
|
||||
tag: parentBackdropImageTags?.first
|
||||
).URLString
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
func getSeriesPrimaryImage(maxWidth: Int) -> URL {
|
||||
guard let seriesId = seriesId else {
|
||||
return getPrimaryImage(maxWidth: maxWidth)
|
||||
}
|
||||
|
||||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
let urlString = ImageAPI.getItemImageWithRequestBuilder(
|
||||
itemId: seriesId,
|
||||
imageType: .primary,
|
||||
maxWidth: Int(x),
|
||||
quality: 96,
|
||||
tag: seriesPrimaryImageTag
|
||||
).URLString
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
func getSeriesThumbImage(maxWidth: Int) -> URL {
|
||||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
let urlString = ImageAPI.getItemImageWithRequestBuilder(
|
||||
itemId: seriesId ?? "",
|
||||
imageType: .thumb,
|
||||
maxWidth: Int(x),
|
||||
quality: 96,
|
||||
tag: seriesPrimaryImageTag
|
||||
).URLString
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
func getPrimaryImage(maxWidth: Int) -> URL {
|
||||
let imageType = ImageType.primary
|
||||
var imageTag = imageTags?[ImageType.primary.rawValue] ?? ""
|
||||
var imageItemId = id ?? ""
|
||||
|
||||
if imageTag.isEmpty || imageItemId.isEmpty {
|
||||
imageTag = seriesPrimaryImageTag ?? ""
|
||||
imageItemId = seriesId ?? ""
|
||||
}
|
||||
|
||||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
|
||||
let urlString = ImageAPI.getItemImageWithRequestBuilder(
|
||||
itemId: imageItemId,
|
||||
imageType: imageType,
|
||||
maxWidth: Int(x),
|
||||
quality: 96,
|
||||
tag: imageTag
|
||||
).URLString
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
// MARK: Calculations
|
||||
|
||||
func getItemRuntime() -> String? {
|
||||
@ -238,61 +85,8 @@ public extension BaseItemDto {
|
||||
return 0
|
||||
}
|
||||
|
||||
// MARK: ItemType
|
||||
|
||||
enum ItemType: String {
|
||||
case movie = "Movie"
|
||||
case season = "Season"
|
||||
case episode = "Episode"
|
||||
case series = "Series"
|
||||
case boxset = "BoxSet"
|
||||
case collectionFolder = "CollectionFolder"
|
||||
case folder = "Folder"
|
||||
case liveTV = "LiveTV"
|
||||
|
||||
case unknown
|
||||
|
||||
var showDetails: Bool {
|
||||
switch self {
|
||||
case .season, .series:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
public init?(rawValue: String) {
|
||||
let lowerCase = rawValue.lowercased()
|
||||
switch lowerCase {
|
||||
case "movie": self = .movie
|
||||
case "season": self = .season
|
||||
case "episode": self = .episode
|
||||
case "series": self = .series
|
||||
case "boxset": self = .boxset
|
||||
case "collectionfolder": self = .collectionFolder
|
||||
case "folder": self = .folder
|
||||
case "livetv": self = .liveTV
|
||||
default: self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var itemType: ItemType {
|
||||
guard let originalType = type, let knownType = ItemType(rawValue: originalType.rawValue) else { return .unknown }
|
||||
return knownType
|
||||
}
|
||||
|
||||
// MARK: PortraitHeaderViewURL
|
||||
|
||||
func portraitHeaderViewURL(maxWidth: Int) -> URL {
|
||||
switch itemType {
|
||||
case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV:
|
||||
return getPrimaryImage(maxWidth: maxWidth)
|
||||
case .episode:
|
||||
return getSeriesPrimaryImage(maxWidth: maxWidth)
|
||||
case .unknown:
|
||||
return getPrimaryImage(maxWidth: maxWidth)
|
||||
}
|
||||
var displayName: String {
|
||||
name ?? "--"
|
||||
}
|
||||
|
||||
// MARK: ItemDetail
|
||||
@ -329,13 +123,13 @@ public extension BaseItemDto {
|
||||
|
||||
if !audioStreams.isEmpty {
|
||||
let audioList = audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
|
||||
.joined(separator: ", ")
|
||||
.joined(separator: "\n")
|
||||
mediaItems.append(ItemDetail(title: L10n.audio, content: audioList))
|
||||
}
|
||||
|
||||
if !subtitleStreams.isEmpty {
|
||||
let subtitleList = subtitleStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
|
||||
.joined(separator: ", ")
|
||||
.joined(separator: "\n")
|
||||
mediaItems.append(ItemDetail(title: L10n.subtitles, content: subtitleList))
|
||||
}
|
||||
}
|
||||
@ -343,6 +137,14 @@ public extension BaseItemDto {
|
||||
return mediaItems
|
||||
}
|
||||
|
||||
var subtitleStreams: [MediaStream] {
|
||||
mediaStreams?.filter { $0.type == .subtitle } ?? []
|
||||
}
|
||||
|
||||
var audioStreams: [MediaStream] {
|
||||
mediaStreams?.filter { $0.type == .audio } ?? []
|
||||
}
|
||||
|
||||
// MARK: Missing and Unaired
|
||||
|
||||
var missing: Bool {
|
||||
@ -370,6 +172,13 @@ public extension BaseItemDto {
|
||||
return dateFormatter.string(from: premiereDate)
|
||||
}
|
||||
|
||||
var premiereDateYear: String? {
|
||||
guard let premiereDate = premiereDate else { return nil }
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "YYYY"
|
||||
return dateFormatter.string(from: premiereDate)
|
||||
}
|
||||
|
||||
// MARK: Chapter Images
|
||||
|
||||
func getChapterImage(maxWidth: Int) -> [URL] {
|
||||
@ -389,4 +198,52 @@ public extension BaseItemDto {
|
||||
|
||||
return chapterImageURLs
|
||||
}
|
||||
|
||||
// TODO: Don't use spoof objects as a placeholder or no results
|
||||
|
||||
static var placeHolder: BaseItemDto {
|
||||
.init(
|
||||
name: "Placeholder",
|
||||
id: "1",
|
||||
overview: String(repeating: "a", count: 100),
|
||||
indexNumber: 20
|
||||
)
|
||||
}
|
||||
|
||||
static var noResults: BaseItemDto {
|
||||
.init(name: L10n.noResults)
|
||||
}
|
||||
}
|
||||
|
||||
extension BaseItemDtoImageBlurHashes {
|
||||
subscript(imageType: ImageType) -> [String: String]? {
|
||||
switch imageType {
|
||||
case .primary:
|
||||
return primary
|
||||
case .art:
|
||||
return art
|
||||
case .backdrop:
|
||||
return backdrop
|
||||
case .banner:
|
||||
return banner
|
||||
case .logo:
|
||||
return logo
|
||||
case .thumb:
|
||||
return thumb
|
||||
case .disc:
|
||||
return disc
|
||||
case .box:
|
||||
return box
|
||||
case .screenshot:
|
||||
return screenshot
|
||||
case .menu:
|
||||
return menu
|
||||
case .chapter:
|
||||
return chapter
|
||||
case .boxRear:
|
||||
return boxRear
|
||||
case .profile:
|
||||
return profile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
||||
// MARK: Get Image
|
||||
|
||||
func getImage(baseURL: String, maxWidth: Int) -> URL {
|
||||
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
|
||||
|
||||
let urlString = ImageAPI.getItemImageWithRequestBuilder(
|
||||
itemId: id ?? "",
|
||||
imageType: .primary,
|
||||
maxWidth: Int(x),
|
||||
quality: 96,
|
||||
tag: primaryImageTag
|
||||
).URLString
|
||||
return URL(string: urlString)!
|
||||
}
|
||||
|
||||
func getBlurHash() -> String {
|
||||
let imgURL = getImage(baseURL: "", maxWidth: 1)
|
||||
guard let imgTag = imgURL.queryParameters?["tag"],
|
||||
let hash = imageBlurHashes?.primary?[imgTag]
|
||||
else {
|
||||
return "001fC^"
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
// MARK: First Role
|
||||
|
||||
// Jellyfin will grab all roles the person played in the show which makes the role
|
||||
// text too long. This will grab the first role which:
|
||||
// - assumes that the most important role is the first
|
||||
// - will also grab the last "(<text>)" instance, like "(voice)"
|
||||
func firstRole() -> String? {
|
||||
var firstRole: String? {
|
||||
guard let role = self.role else { return nil }
|
||||
let split = role.split(separator: "/")
|
||||
guard split.count > 1 else { return role }
|
||||
@ -61,56 +35,18 @@ extension BaseItemPerson {
|
||||
|
||||
return final
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: PortraitImageStackable
|
||||
|
||||
extension BaseItemPerson: PortraitImageStackable {
|
||||
public var portraitImageID: String {
|
||||
(id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials
|
||||
}
|
||||
|
||||
public func imageURLConstructor(maxWidth: Int) -> URL {
|
||||
self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth)
|
||||
}
|
||||
|
||||
public var title: String {
|
||||
self.name ?? ""
|
||||
}
|
||||
|
||||
public var subtitle: String? {
|
||||
self.firstRole()
|
||||
}
|
||||
|
||||
public var blurHash: String {
|
||||
self.getBlurHash()
|
||||
}
|
||||
|
||||
public var failureInitials: String {
|
||||
guard let name = self.name else { return "" }
|
||||
let initials = name.split(separator: " ").compactMap { String($0).first }
|
||||
return String(initials)
|
||||
}
|
||||
|
||||
public var showTitle: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: DiplayedType
|
||||
|
||||
extension BaseItemPerson {
|
||||
|
||||
// Only displayed person types.
|
||||
// Will ignore people like "GuestStar"
|
||||
enum DisplayedType: String, CaseIterable {
|
||||
// Will ignore types like "GuestStar"
|
||||
enum DisplayedType: String {
|
||||
case actor = "Actor"
|
||||
case director = "Director"
|
||||
case writer = "Writer"
|
||||
case producer = "Producer"
|
||||
}
|
||||
|
||||
static var allCasesRaw: [String] {
|
||||
self.allCases.map(\.rawValue)
|
||||
}
|
||||
var isDisplayed: Bool {
|
||||
guard let type = type else { return false }
|
||||
return DisplayedType(rawValue: type) != nil
|
||||
}
|
||||
}
|
||||
|
@ -7,10 +7,10 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import JellyfinAPI
|
||||
|
||||
extension View {
|
||||
func eraseToAnyView() -> AnyView {
|
||||
AnyView(self)
|
||||
extension RequestBuilder where T == URL {
|
||||
var url: URL {
|
||||
URL(string: URLString)!
|
||||
}
|
||||
}
|
@ -37,4 +37,9 @@ extension String {
|
||||
var text: Text {
|
||||
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 {
|
||||
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
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
struct JellyfinWidgetBundle: WidgetBundle {
|
||||
@WidgetBundleBuilder
|
||||
var body: some Widget {
|
||||
NextUpWidget()
|
||||
extension UIScreen {
|
||||
func scale(_ x: Int) -> Int {
|
||||
Int(nativeScale) * x
|
||||
}
|
||||
|
||||
func scale(_ x: CGFloat) -> Int {
|
||||
Int(nativeScale * x)
|
||||
}
|
||||
}
|
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 UIKit
|
||||
|
||||
// TODO: Refactor...
|
||||
|
||||
extension SwiftfinStore {
|
||||
enum Defaults {
|
||||
static let generalSuite: UserDefaults = .init(suiteName: "swiftfinstore-general-defaults")!
|
||||
@ -39,6 +41,7 @@ extension Defaults.Keys {
|
||||
static let showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let showCastAndCrew = Key<Bool>("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let showFlattenView = Key<Bool>("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let itemViewType = Key<ItemViewType>("itemViewType", default: .compactLogo, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
|
||||
// Video player / overlay settings
|
||||
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
@ -116,5 +119,4 @@ extension Defaults.Keys {
|
||||
// tvos specific
|
||||
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let confirmClose = Key<Bool>("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
static let tvOSCinematicViews = Key<Bool>("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite)
|
||||
}
|
||||
|
@ -14,9 +14,11 @@ protocol EpisodesRowManager: ViewModel {
|
||||
var item: BaseItemDto { get }
|
||||
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set }
|
||||
var selectedSeason: BaseItemDto? { get set }
|
||||
func retrieveSeasons()
|
||||
func retrieveEpisodesForSeason(_ season: BaseItemDto)
|
||||
|
||||
func getSeasons()
|
||||
func getEpisodesForSeason(_ season: BaseItemDto)
|
||||
func select(season: BaseItemDto)
|
||||
func select(seasonID: String)
|
||||
}
|
||||
|
||||
extension EpisodesRowManager {
|
||||
@ -25,10 +27,19 @@ extension EpisodesRowManager {
|
||||
Array(seasonsEpisodes.keys).sorted(by: { $0.indexNumber ?? 0 < $1.indexNumber ?? 0 })
|
||||
}
|
||||
|
||||
var currentEpisodes: [BaseItemDto]? {
|
||||
if let selectedSeason = selectedSeason {
|
||||
return seasonsEpisodes[selectedSeason]
|
||||
} else {
|
||||
guard let firstSeason = seasonsEpisodes.keys.first else { return nil }
|
||||
return seasonsEpisodes[firstSeason]
|
||||
}
|
||||
}
|
||||
|
||||
// Also retrieves the current season episodes if available
|
||||
func retrieveSeasons() {
|
||||
func getSeasons() {
|
||||
TvShowsAPI.getSeasons(
|
||||
seriesId: item.seriesId ?? "",
|
||||
seriesId: item.id ?? "",
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false
|
||||
)
|
||||
@ -36,26 +47,21 @@ extension EpisodesRowManager {
|
||||
self.handleAPIRequestError(completion: completion)
|
||||
} receiveValue: { response in
|
||||
let seasons = response.items ?? []
|
||||
|
||||
seasons.forEach { season in
|
||||
self.seasonsEpisodes[season] = []
|
||||
|
||||
if season.id == self.item.seasonId ?? "" {
|
||||
self.selectedSeason = season
|
||||
self.retrieveEpisodesForSeason(season)
|
||||
} else if season.id == self.item.id ?? "" {
|
||||
self.selectedSeason = season
|
||||
self.retrieveEpisodesForSeason(season)
|
||||
}
|
||||
}
|
||||
|
||||
self.selectedSeason = seasons.first
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func retrieveEpisodesForSeason(_ season: BaseItemDto) {
|
||||
func getEpisodesForSeason(_ season: BaseItemDto) {
|
||||
guard let seasonID = season.id else { return }
|
||||
|
||||
TvShowsAPI.getEpisodes(
|
||||
seriesId: item.seriesId ?? "",
|
||||
seriesId: item.id ?? "",
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
seasonId: seasonID,
|
||||
@ -74,7 +80,12 @@ extension EpisodesRowManager {
|
||||
self.selectedSeason = season
|
||||
|
||||
if seasonsEpisodes[season]!.isEmpty {
|
||||
retrieveEpisodesForSeason(season)
|
||||
getEpisodesForSeason(season)
|
||||
}
|
||||
}
|
||||
|
||||
func select(seasonID: String) {
|
||||
guard let selectedSeason = Array(seasonsEpisodes.keys).first(where: { $0.id == seasonID }) else { return }
|
||||
select(season: selectedSeason)
|
||||
}
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ final class HomeViewModel: ViewModel {
|
||||
var libraries: [BaseItemDto] = []
|
||||
|
||||
// temp
|
||||
var recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded])
|
||||
static let recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded])
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@ -139,7 +139,7 @@ final class HomeViewModel: ViewModel {
|
||||
includeItemTypes: [.movie, .series],
|
||||
enableImageTypes: [.primary, .backdrop, .thumb],
|
||||
enableUserData: true,
|
||||
limit: 8
|
||||
limit: 20
|
||||
)
|
||||
.sink { completion in
|
||||
switch completion {
|
||||
@ -161,7 +161,7 @@ final class HomeViewModel: ViewModel {
|
||||
private func refreshResumeItems() {
|
||||
ItemsAPI.getResumeItems(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
limit: 6,
|
||||
limit: 20,
|
||||
fields: [
|
||||
.primaryImageAspectRatio,
|
||||
.seriesPrimaryImage,
|
||||
@ -210,7 +210,7 @@ final class HomeViewModel: ViewModel {
|
||||
private func refreshNextUpItems() {
|
||||
TvShowsAPI.getNextUp(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
limit: 6,
|
||||
limit: 20,
|
||||
fields: [
|
||||
.primaryImageAspectRatio,
|
||||
.seriesPrimaryImage,
|
||||
|
@ -25,7 +25,7 @@ final class CollectionItemViewModel: ItemViewModel {
|
||||
ItemsAPI.getItems(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
parentId: item.id,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]
|
||||
fields: ItemFields.allCases
|
||||
)
|
||||
.trackActivity(loading)
|
||||
.sink { [weak self] completion in
|
||||
|
@ -11,39 +11,22 @@ import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
|
||||
final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||
final class EpisodeItemViewModel: ItemViewModel {
|
||||
|
||||
@RouterObject
|
||||
var itemRouter: ItemCoordinator.Router?
|
||||
private var itemRouter: ItemCoordinator.Router?
|
||||
@Published
|
||||
var series: BaseItemDto?
|
||||
var playButtonText: String = ""
|
||||
@Published
|
||||
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
|
||||
@Published
|
||||
var selectedSeason: BaseItemDto?
|
||||
var mediaDetailItems: [[BaseItemDto.ItemDetail]] = []
|
||||
|
||||
override init(item: BaseItemDto) {
|
||||
super.init(item: item)
|
||||
|
||||
getEpisodeSeries()
|
||||
retrieveSeasons()
|
||||
}
|
||||
|
||||
override func getItemDisplayName() -> String {
|
||||
guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" }
|
||||
return "\(episodeLocator)\n\(item.name ?? "")"
|
||||
}
|
||||
|
||||
func getEpisodeSeries() {
|
||||
guard let id = item.seriesId else { return }
|
||||
UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] item in
|
||||
self?.series = item
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
$videoPlayerViewModels.sink(receiveValue: { newValue in
|
||||
self.mediaDetailItems = self.createMediaDetailItems(viewModels: newValue)
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
override func updateItem() {
|
||||
@ -72,4 +55,30 @@ final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func createMediaDetailItems(viewModels: [VideoPlayerViewModel]) -> [[BaseItemDto.ItemDetail]] {
|
||||
var fileMediaItems: [[BaseItemDto.ItemDetail]] = []
|
||||
|
||||
for viewModel in viewModels {
|
||||
|
||||
let audioStreams = viewModel.audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
|
||||
.joined(separator: ", ")
|
||||
|
||||
let subtitleStreams = viewModel.subtitleStreams
|
||||
.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
|
||||
.joined(separator: ", ")
|
||||
|
||||
let currentMediaItems: [BaseItemDto.ItemDetail] = [
|
||||
.init(title: "File", content: viewModel.filename ?? "--"),
|
||||
.init(title: "Audio", content: audioStreams),
|
||||
.init(title: "Subtitles", content: subtitleStreams),
|
||||
]
|
||||
|
||||
fileMediaItems.append(currentMediaItems)
|
||||
}
|
||||
|
||||
// print(fileMediaItems)
|
||||
|
||||
return fileMediaItems
|
||||
}
|
||||
}
|
||||
|
@ -31,15 +31,15 @@ class ItemViewModel: ViewModel {
|
||||
@Published
|
||||
var isFavorited = false
|
||||
@Published
|
||||
var informationItems: [BaseItemDto.ItemDetail]
|
||||
@Published
|
||||
var selectedVideoPlayerViewModel: VideoPlayerViewModel?
|
||||
@Published
|
||||
var videoPlayerViewModels: [VideoPlayerViewModel] = []
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
super.init()
|
||||
|
||||
switch item.itemType {
|
||||
switch item.type {
|
||||
case .episode, .movie:
|
||||
if !item.missing && !item.unaired {
|
||||
self.playButtonItem = item
|
||||
@ -47,17 +47,13 @@ class ItemViewModel: ViewModel {
|
||||
default: ()
|
||||
}
|
||||
|
||||
informationItems = item.createInformationItems()
|
||||
|
||||
isFavorited = item.userData?.isFavorite ?? false
|
||||
isWatched = item.userData?.played ?? false
|
||||
super.init()
|
||||
|
||||
getSimilarItems()
|
||||
refreshItemVideoPlayerViewModel(for: item)
|
||||
|
||||
Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:)))
|
||||
|
||||
refreshItemVideoPlayerViewModel(for: item)
|
||||
}
|
||||
|
||||
@objc
|
||||
@ -74,7 +70,7 @@ class ItemViewModel: ViewModel {
|
||||
}
|
||||
|
||||
func refreshItemVideoPlayerViewModel(for item: BaseItemDto) {
|
||||
guard item.itemType == .episode || item.itemType == .movie else { return }
|
||||
guard item.type == .episode || item.type == .movie else { return }
|
||||
guard !item.missing, !item.unaired else { return }
|
||||
|
||||
item.createVideoPlayerViewModel()
|
||||
@ -104,20 +100,12 @@ class ItemViewModel: ViewModel {
|
||||
return L10n.play
|
||||
}
|
||||
|
||||
func getItemDisplayName() -> String {
|
||||
item.name ?? ""
|
||||
}
|
||||
|
||||
func shouldDisplayRuntime() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func getSimilarItems() {
|
||||
LibraryAPI.getSimilarItems(
|
||||
itemId: item.id!,
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
limit: 10,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]
|
||||
limit: 20,
|
||||
fields: ItemFields.allCases
|
||||
)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
@ -128,54 +116,52 @@ class ItemViewModel: ViewModel {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func updateWatchState() {
|
||||
if isWatched {
|
||||
PlaystateAPI.markUnplayedItem(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
itemId: item.id!
|
||||
)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isWatched = false
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
func toggleWatchState() {
|
||||
let current = isWatched
|
||||
isWatched.toggle()
|
||||
let request: AnyPublisher<UserItemDataDto, Error>
|
||||
|
||||
if current {
|
||||
request = PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||
} else {
|
||||
PlaystateAPI.markPlayedItem(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
itemId: item.id!
|
||||
)
|
||||
request = PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||
}
|
||||
|
||||
request
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
switch completion {
|
||||
case .failure:
|
||||
self?.isWatched = !current
|
||||
case .finished: ()
|
||||
}
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isWatched = true
|
||||
})
|
||||
}, receiveValue: { _ in })
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
func updateFavoriteState() {
|
||||
if isFavorited {
|
||||
UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isFavorited = false
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
func toggleFavoriteState() {
|
||||
let current = isFavorited
|
||||
isFavorited.toggle()
|
||||
let request: AnyPublisher<UserItemDataDto, Error>
|
||||
|
||||
if current {
|
||||
request = UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||
} else {
|
||||
UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.isFavorited = true
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
request = UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
|
||||
}
|
||||
|
||||
request
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
switch completion {
|
||||
case .failure:
|
||||
self?.isFavorited = !current
|
||||
case .finished: ()
|
||||
}
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { _ in })
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// Overridden by subclasses
|
||||
|
@ -14,11 +14,7 @@ import Stinsen
|
||||
final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||
|
||||
@RouterObject
|
||||
var itemRouter: ItemCoordinator.Router?
|
||||
@Published
|
||||
var episodes: [BaseItemDto] = []
|
||||
@Published
|
||||
var seriesItem: BaseItemDto?
|
||||
private var itemRouter: ItemCoordinator.Router?
|
||||
@Published
|
||||
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
|
||||
@Published
|
||||
@ -27,9 +23,8 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||
override init(item: BaseItemDto) {
|
||||
super.init(item: item)
|
||||
|
||||
getSeriesItem()
|
||||
selectedSeason = item
|
||||
retrieveSeasons()
|
||||
// getSeasons()
|
||||
requestEpisodes()
|
||||
}
|
||||
|
||||
@ -39,7 +34,7 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||
return L10n.unaired
|
||||
}
|
||||
|
||||
guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play }
|
||||
guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.episodeLocator else { return L10n.play }
|
||||
return episodeLocator
|
||||
}
|
||||
|
||||
@ -57,8 +52,7 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
self.episodes = response.items ?? []
|
||||
LogManager.log.debug("Retrieved \(String(self.episodes.count)) episodes")
|
||||
self.seasonsEpisodes[self.item] = response.items ?? []
|
||||
|
||||
self.setNextUpInSeason()
|
||||
})
|
||||
@ -87,44 +81,29 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||
LogManager.log.debug("Nextup in season \(self.item.id!) (\(self.item.name!)): \(nextUpItem.id!)")
|
||||
}
|
||||
|
||||
if self.playButtonItem == nil && !self.episodes.isEmpty {
|
||||
// Fallback to the old mechanism:
|
||||
// Sets the play button item to the "Next up" in the season based upon
|
||||
// the watched status of episodes in the season.
|
||||
// Default to the first episode of the season if all have been watched.
|
||||
var firstUnwatchedSearch: BaseItemDto?
|
||||
|
||||
for episode in self.episodes {
|
||||
guard let played = episode.userData?.played else { continue }
|
||||
if !played {
|
||||
firstUnwatchedSearch = episode
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let firstUnwatched = firstUnwatchedSearch {
|
||||
self.playButtonItem = firstUnwatched
|
||||
} else {
|
||||
guard let firstEpisode = self.episodes.first else { return }
|
||||
self.playButtonItem = firstEpisode
|
||||
}
|
||||
}
|
||||
// if self.playButtonItem == nil && !self.episodes.isEmpty {
|
||||
// // Fallback to the old mechanism:
|
||||
// // Sets the play button item to the "Next up" in the season based upon
|
||||
// // the watched status of episodes in the season.
|
||||
// // Default to the first episode of the season if all have been watched.
|
||||
// var firstUnwatchedSearch: BaseItemDto?
|
||||
//
|
||||
// for episode in self.episodes {
|
||||
// guard let played = episode.userData?.played else { continue }
|
||||
// if !played {
|
||||
// firstUnwatchedSearch = episode
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if let firstUnwatched = firstUnwatchedSearch {
|
||||
// self.playButtonItem = firstUnwatched
|
||||
// } else {
|
||||
// guard let firstEpisode = self.episodes.first else { return }
|
||||
// self.playButtonItem = firstEpisode
|
||||
// }
|
||||
// }
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func getSeriesItem() {
|
||||
guard let seriesID = item.seriesId else { return }
|
||||
UserLibraryAPI.getItem(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
itemId: seriesID
|
||||
)
|
||||
.trackActivity(loading)
|
||||
.sink { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
} receiveValue: { [weak self] seriesItem in
|
||||
self?.seriesItem = seriesItem
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
@ -11,16 +11,25 @@ import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
final class SeriesItemViewModel: ItemViewModel {
|
||||
final class SeriesItemViewModel: ItemViewModel, EpisodesRowManager {
|
||||
|
||||
@Published
|
||||
var seasons: [BaseItemDto] = []
|
||||
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
|
||||
@Published
|
||||
var selectedSeason: BaseItemDto?
|
||||
|
||||
override init(item: BaseItemDto) {
|
||||
super.init(item: item)
|
||||
|
||||
requestSeasons()
|
||||
getSeasons()
|
||||
|
||||
// The server won't have both a next up item
|
||||
// and a resume item at the same time, so they
|
||||
// control the button first. Also fetch first available
|
||||
// item, which may be overwritten by next up or resume.
|
||||
getNextUp()
|
||||
getResumeItem()
|
||||
getFirstAvailableItem()
|
||||
}
|
||||
|
||||
override func playButtonText() -> String {
|
||||
@ -33,20 +42,16 @@ final class SeriesItemViewModel: ItemViewModel {
|
||||
return L10n.missing
|
||||
}
|
||||
|
||||
guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play }
|
||||
guard let playButtonItem = playButtonItem,
|
||||
let episodeLocator = playButtonItem.seasonEpisodeLocator else { return L10n.play }
|
||||
|
||||
return episodeLocator
|
||||
}
|
||||
|
||||
override func shouldDisplayRuntime() -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
private func getNextUp() {
|
||||
|
||||
LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))")
|
||||
TvShowsAPI.getNextUp(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
seriesId: self.item.id!,
|
||||
enableUserData: true
|
||||
)
|
||||
@ -56,12 +61,64 @@ final class SeriesItemViewModel: ItemViewModel {
|
||||
}, receiveValue: { [weak self] response in
|
||||
if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing {
|
||||
self?.playButtonItem = nextUpItem
|
||||
|
||||
if let seasonID = nextUpItem.seasonId {
|
||||
self?.select(seasonID: seasonID)
|
||||
}
|
||||
}
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func getRunYears() -> String {
|
||||
private func getResumeItem() {
|
||||
ItemsAPI.getResumeItems(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
limit: 1,
|
||||
parentId: item.id
|
||||
)
|
||||
.trackActivity(loading)
|
||||
.sink { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
} receiveValue: { [weak self] response in
|
||||
if let firstItem = response.items?.first {
|
||||
self?.playButtonItem = firstItem
|
||||
|
||||
if let seasonID = firstItem.seasonId {
|
||||
self?.select(seasonID: seasonID)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func getFirstAvailableItem() {
|
||||
ItemsAPI.getItemsByUserId(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
limit: 2,
|
||||
recursive: true,
|
||||
sortOrder: [.ascending],
|
||||
parentId: item.id,
|
||||
includeItemTypes: [.episode]
|
||||
)
|
||||
.trackActivity(loading)
|
||||
.sink { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
} receiveValue: { [weak self] response in
|
||||
if let firstItem = response.items?.first {
|
||||
if self?.playButtonItem == nil {
|
||||
// If other calls finish after this, it will be overwritten
|
||||
self?.playButtonItem = firstItem
|
||||
|
||||
if let seasonID = firstItem.seasonId {
|
||||
self?.select(seasonID: seasonID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func getRunYears() -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy"
|
||||
|
||||
@ -78,23 +135,4 @@ final class SeriesItemViewModel: ItemViewModel {
|
||||
|
||||
return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)"
|
||||
}
|
||||
|
||||
private func requestSeasons() {
|
||||
LogManager.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))")
|
||||
TvShowsAPI.getSeasons(
|
||||
seriesId: item.id ?? "",
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false,
|
||||
enableUserData: true
|
||||
)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.seasons = response.items ?? []
|
||||
LogManager.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons")
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
@ -29,14 +29,7 @@ final class LatestMediaViewModel: ViewModel {
|
||||
UserLibraryAPI.getLatestMedia(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
parentId: library.id ?? "",
|
||||
fields: [
|
||||
.primaryImageAspectRatio,
|
||||
.seriesPrimaryImage,
|
||||
.seasonUserData,
|
||||
.overview,
|
||||
.genres,
|
||||
.people,
|
||||
],
|
||||
fields: ItemFields.allCases,
|
||||
includeItemTypes: [.series, .movie],
|
||||
enableUserData: true,
|
||||
limit: 12
|
||||
|
@ -6,6 +6,7 @@
|
||||
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
@ -14,8 +15,18 @@ final class LibraryListViewModel: ViewModel {
|
||||
@Published
|
||||
var libraries: [BaseItemDto] = []
|
||||
|
||||
var filteredLibraries: [BaseItemDto] {
|
||||
var supportedLibraries = ["movies", "tvshows", "unknown"]
|
||||
|
||||
if Defaults[.Experimental.liveTVAlphaEnabled] {
|
||||
supportedLibraries.append("livetv")
|
||||
}
|
||||
|
||||
return libraries.filter { supportedLibraries.contains($0.collectionType ?? "unknown") }
|
||||
}
|
||||
|
||||
// temp
|
||||
var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: [])
|
||||
let withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: [])
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
@ -115,15 +115,7 @@ final class LibraryViewModel: ViewModel {
|
||||
searchTerm: nil,
|
||||
sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) },
|
||||
parentId: parentID,
|
||||
fields: [
|
||||
.primaryImageAspectRatio,
|
||||
.seriesPrimaryImage,
|
||||
.seasonUserData,
|
||||
.overview,
|
||||
.genres,
|
||||
.people,
|
||||
.chapters,
|
||||
],
|
||||
fields: ItemFields.allCases,
|
||||
includeItemTypes: includeItemTypes,
|
||||
filters: filters.filters,
|
||||
sortBy: sortBy,
|
||||
|
@ -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
|
||||
|
||||
@RouterObject
|
||||
var router: MovieLibrariesCoordinator.Router?
|
||||
private var router: MovieLibrariesCoordinator.Router?
|
||||
|
||||
init(columns: Int = 7) {
|
||||
self.columns = columns
|
||||
|
@ -29,7 +29,7 @@ final class TVLibrariesViewModel: ViewModel {
|
||||
private let columns: Int
|
||||
|
||||
@RouterObject
|
||||
var router: TVLibrariesCoordinator.Router?
|
||||
private var router: TVLibrariesCoordinator.Router?
|
||||
|
||||
init(columns: Int = 7) {
|
||||
self.columns = columns
|
||||
|
@ -14,7 +14,7 @@ import Stinsen
|
||||
final class UserSignInViewModel: ViewModel {
|
||||
|
||||
@RouterObject
|
||||
var router: UserSignInCoordinator.Router?
|
||||
private var Router: UserSignInCoordinator.Router?
|
||||
|
||||
@Published
|
||||
var publicUsers: [UserDto] = []
|
||||
|
@ -123,6 +123,7 @@ final class VideoPlayerViewModel: ViewModel {
|
||||
let directStreamURL: URL
|
||||
let transcodedStreamURL: URL?
|
||||
let hlsStreamURL: URL
|
||||
let videoStream: MediaStream
|
||||
let audioStreams: [MediaStream]
|
||||
let subtitleStreams: [MediaStream]
|
||||
let chapters: [ChapterInfo]
|
||||
@ -220,6 +221,7 @@ final class VideoPlayerViewModel: ViewModel {
|
||||
hlsStreamURL: URL,
|
||||
streamType: ServerStreamType,
|
||||
response: PlaybackInfoResponse,
|
||||
videoStream: MediaStream,
|
||||
audioStreams: [MediaStream],
|
||||
subtitleStreams: [MediaStream],
|
||||
chapters: [ChapterInfo],
|
||||
@ -243,6 +245,7 @@ final class VideoPlayerViewModel: ViewModel {
|
||||
self.hlsStreamURL = hlsStreamURL
|
||||
self.streamType = streamType
|
||||
self.response = response
|
||||
self.videoStream = videoStream
|
||||
self.audioStreams = audioStreams
|
||||
self.subtitleStreams = subtitleStreams
|
||||
self.chapters = chapters
|
||||
@ -333,7 +336,7 @@ extension VideoPlayerViewModel {
|
||||
|
||||
extension VideoPlayerViewModel {
|
||||
func getAdjacentEpisodes() {
|
||||
guard let seriesID = item.seriesId, item.itemType == .episode else { return }
|
||||
guard let seriesID = item.seriesId, item.type == .episode else { return }
|
||||
|
||||
TvShowsAPI.getEpisodes(
|
||||
seriesId: seriesID,
|
||||
|
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 {
|
||||
|
||||
let blurHash: String
|
||||
private let blurHash: String
|
||||
private let size: CGSize
|
||||
|
||||
init(blurHash: String, size: CGSize = .Circle(radius: 12)) {
|
||||
self.blurHash = blurHash
|
||||
self.size = size
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIBlurHashView {
|
||||
UIBlurHashView(blurHash)
|
||||
UIBlurHashView(blurHash, size: size)
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIBlurHashView, context: Context) {}
|
||||
@ -24,14 +30,14 @@ class UIBlurHashView: UIView {
|
||||
|
||||
private let imageView: UIImageView
|
||||
|
||||
init(_ blurHash: String) {
|
||||
init(_ blurHash: String, size: CGSize) {
|
||||
let imageView = UIImageView()
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.imageView = imageView
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
computeBlurHashImageAsync(blurHash: blurHash) { [weak self] blurImage in
|
||||
computeBlurHashImageAsync(blurHash: blurHash, size: size) { [weak self] blurImage in
|
||||
guard let self = self else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.imageView.image = blurImage
|
||||
@ -54,9 +60,9 @@ class UIBlurHashView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func computeBlurHashImageAsync(blurHash: String, _ completion: @escaping (UIImage?) -> Void) {
|
||||
private func computeBlurHashImageAsync(blurHash: String, size: CGSize, _ completion: @escaping (UIImage?) -> Void) {
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let image = UIImage(blurHash: blurHash, size: .Circle(radius: 12))
|
||||
let image = UIImage(blurHash: blurHash, size: size)
|
||||
completion(image)
|
||||
}
|
||||
}
|
||||
|
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
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct LazyView<Content: View>: View {
|
||||
var content: () -> Content
|
||||
struct Divider: View {
|
||||
|
||||
var body: some View {
|
||||
self.content()
|
||||
Color.secondarySystemFill
|
||||
.frame(height: 0.5)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
@ -11,9 +11,7 @@ import NukeUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// TODO: Fix 100+ inits
|
||||
|
||||
struct ImageViewSource {
|
||||
struct ImageSource {
|
||||
let url: URL?
|
||||
let blurHash: String?
|
||||
|
||||
@ -33,25 +31,38 @@ struct DefaultFailureView: View {
|
||||
struct ImageView<FailureView: View>: View {
|
||||
|
||||
@State
|
||||
private var sources: [ImageViewSource]
|
||||
private var sources: [ImageSource]
|
||||
private var currentURL: URL? { sources.first?.url }
|
||||
private var currentBlurHash: String? { sources.first?.blurHash }
|
||||
private var failureView: FailureView
|
||||
private var failureView: () -> FailureView
|
||||
private var resizingMode: ImageResizingMode
|
||||
|
||||
init(_ source: URL?, blurHash: String? = nil, @ViewBuilder failureView: () -> FailureView) {
|
||||
let imageViewSource = ImageViewSource(url: source, blurHash: blurHash)
|
||||
_sources = State(initialValue: [imageViewSource])
|
||||
self.failureView = failureView()
|
||||
init(
|
||||
_ source: URL?,
|
||||
blurHash: String? = nil,
|
||||
resizingMode: ImageResizingMode = .aspectFill,
|
||||
@ViewBuilder failureView: @escaping () -> FailureView
|
||||
) {
|
||||
let imageSource = ImageSource(url: source, blurHash: blurHash)
|
||||
self.init(imageSource, resizingMode: resizingMode, failureView: failureView)
|
||||
}
|
||||
|
||||
init(_ source: ImageViewSource, @ViewBuilder failureView: () -> FailureView) {
|
||||
_sources = State(initialValue: [source])
|
||||
self.failureView = failureView()
|
||||
init(
|
||||
_ source: ImageSource,
|
||||
resizingMode: ImageResizingMode = .aspectFill,
|
||||
@ViewBuilder failureView: @escaping () -> FailureView
|
||||
) {
|
||||
self.init([source], resizingMode: resizingMode, failureView: failureView)
|
||||
}
|
||||
|
||||
init(_ sources: [ImageViewSource], @ViewBuilder failureView: () -> FailureView) {
|
||||
init(
|
||||
_ sources: [ImageSource],
|
||||
resizingMode: ImageResizingMode = .aspectFill,
|
||||
@ViewBuilder failureView: @escaping () -> FailureView
|
||||
) {
|
||||
_sources = State(initialValue: sources)
|
||||
self.failureView = failureView()
|
||||
self.resizingMode = resizingMode
|
||||
self.failureView = failureView
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -60,18 +71,20 @@ struct ImageView<FailureView: View>: View {
|
||||
BlurHashView(blurHash: currentBlurHash)
|
||||
.id(currentBlurHash)
|
||||
} else {
|
||||
Color.secondary
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
if let currentURL = currentURL {
|
||||
LazyImage(source: currentURL) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizingMode(resizingMode)
|
||||
} else if state.error != nil {
|
||||
placeholderView.onAppear { sources.removeFirst() }
|
||||
placeholderView.onAppear {
|
||||
sources.removeFirst()
|
||||
}
|
||||
} else {
|
||||
placeholderView
|
||||
}
|
||||
@ -79,27 +92,27 @@ struct ImageView<FailureView: View>: View {
|
||||
.pipeline(ImagePipeline(configuration: .withDataCache))
|
||||
.id(currentURL)
|
||||
} else {
|
||||
failureView
|
||||
failureView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageView where FailureView == DefaultFailureView {
|
||||
init(_ source: URL?, blurHash: String? = nil) {
|
||||
let imageViewSource = ImageViewSource(url: source, blurHash: blurHash)
|
||||
self.init(imageViewSource, failureView: { DefaultFailureView() })
|
||||
init(_ source: URL?, blurHash: String? = nil, resizingMode: ImageResizingMode = .aspectFill) {
|
||||
let imageSource = ImageSource(url: source, blurHash: blurHash)
|
||||
self.init([imageSource], resizingMode: resizingMode, failureView: { DefaultFailureView() })
|
||||
}
|
||||
|
||||
init(_ source: ImageViewSource) {
|
||||
self.init(source, failureView: { DefaultFailureView() })
|
||||
init(_ source: ImageSource, resizingMode: ImageResizingMode = .aspectFill) {
|
||||
self.init([source], resizingMode: resizingMode, failureView: { DefaultFailureView() })
|
||||
}
|
||||
|
||||
init(_ sources: [ImageViewSource]) {
|
||||
self.init(sources, failureView: { DefaultFailureView() })
|
||||
init(_ sources: [ImageSource], resizingMode: ImageResizingMode = .aspectFill) {
|
||||
self.init(sources, resizingMode: resizingMode, failureView: { DefaultFailureView() })
|
||||
}
|
||||
|
||||
init(sources: [URL]) {
|
||||
let imageViewSources = sources.compactMap { ImageViewSource(url: $0, blurHash: nil) }
|
||||
self.init(imageViewSources, failureView: { DefaultFailureView() })
|
||||
init(sources: [URL], resizingMode: ImageResizingMode = .aspectFill) {
|
||||
let imageSources = sources.compactMap { ImageSource(url: $0, blurHash: nil) }
|
||||
self.init(imageSources, resizingMode: resizingMode, failureView: { DefaultFailureView() })
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
if item.itemType == .episode {
|
||||
backdropImage = item.getSeriesBackdropImage(maxWidth: 1920)
|
||||
if item.type == .episode {
|
||||
backdropImage = item.seriesImageURL(.backdrop, maxWidth: 1920)
|
||||
} else {
|
||||
backdropImage = item.getBackdropImage(maxWidth: 1920)
|
||||
backdropImage = item.imageURL(.backdrop, maxWidth: 1920)
|
||||
}
|
||||
|
||||
let options = ImageLoadingOptions(transition: .fadeIn(duration: 0.2))
|
||||
|
@ -12,27 +12,27 @@ import SwiftUI
|
||||
struct CinematicNextUpCardView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
var homeRouter: HomeCoordinator.Router
|
||||
private var homeRouter: HomeCoordinator.Router
|
||||
let item: BaseItemDto
|
||||
let showOverlay: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Button {
|
||||
homeRouter.route(to: \.modalItem, item)
|
||||
homeRouter.route(to: \.item, item)
|
||||
} label: {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
|
||||
if item.itemType == .episode {
|
||||
ImageView(sources: [
|
||||
item.getSeriesThumbImage(maxWidth: 350),
|
||||
item.getSeriesBackdropImage(maxWidth: 350),
|
||||
if item.type == .episode {
|
||||
ImageView([
|
||||
item.seriesImageSource(.thumb, maxWidth: 350),
|
||||
item.seriesImageSource(.backdrop, maxWidth: 350),
|
||||
])
|
||||
.frame(width: 350, height: 210)
|
||||
} else {
|
||||
ImageView([
|
||||
.init(url: item.getThumbImage(maxWidth: 350)),
|
||||
.init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()),
|
||||
item.imageSource(.thumb, maxWidth: 350),
|
||||
item.imageSource(.backdrop, maxWidth: 350),
|
||||
])
|
||||
.frame(width: 350, height: 210)
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import SwiftUI
|
||||
struct CinematicResumeCardView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
var homeRouter: HomeCoordinator.Router
|
||||
private var homeRouter: HomeCoordinator.Router
|
||||
@ObservedObject
|
||||
var viewModel: HomeViewModel
|
||||
let item: BaseItemDto
|
||||
@ -20,20 +20,20 @@ struct CinematicResumeCardView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Button {
|
||||
homeRouter.route(to: \.modalItem, item)
|
||||
homeRouter.route(to: \.item, item)
|
||||
} label: {
|
||||
ZStack(alignment: .bottom) {
|
||||
|
||||
if item.itemType == .episode {
|
||||
ImageView(sources: [
|
||||
item.getSeriesThumbImage(maxWidth: 350),
|
||||
item.getSeriesBackdropImage(maxWidth: 350),
|
||||
if item.type == .episode {
|
||||
ImageView([
|
||||
item.seriesImageSource(.thumb, maxWidth: 350),
|
||||
item.seriesImageSource(.backdrop, maxWidth: 350),
|
||||
])
|
||||
.frame(width: 350, height: 210)
|
||||
} else {
|
||||
ImageView([
|
||||
.init(url: item.getThumbImage(maxWidth: 350)),
|
||||
.init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()),
|
||||
item.imageSource(.thumb, maxWidth: 350),
|
||||
item.imageSource(.backdrop, maxWidth: 350),
|
||||
])
|
||||
.frame(width: 350, height: 210)
|
||||
}
|
||||
@ -54,7 +54,7 @@ struct CinematicResumeCardView: View {
|
||||
.foregroundColor(.white)
|
||||
|
||||
HStack {
|
||||
Color(UIColor.systemPurple)
|
||||
Color.jellyfinPurple
|
||||
.frame(width: 350 * (item.userData?.playedPercentage ?? 0) / 100, height: 7)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
@ -54,7 +54,7 @@ struct HomeCinematicView: View {
|
||||
ZStack(alignment: .bottom) {
|
||||
|
||||
CinematicBackgroundView(viewModel: backgroundViewModel)
|
||||
.frame(height: UIScreen.main.bounds.height - 10)
|
||||
.frame(height: UIScreen.main.bounds.height - 50)
|
||||
|
||||
LinearGradient(
|
||||
stops: [
|
||||
@ -77,8 +77,8 @@ struct HomeCinematicView: View {
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Color.secondary)
|
||||
} else {
|
||||
if updatedSelectedItem?.itemType == .episode {
|
||||
Text(updatedSelectedItem?.getEpisodeLocator() ?? "")
|
||||
if updatedSelectedItem?.type == .episode {
|
||||
Text(updatedSelectedItem?.episodeLocator ?? "")
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Color.secondary)
|
||||
|
@ -8,6 +8,8 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// TODO: Replace and remove
|
||||
|
||||
struct ItemDetailsView: View {
|
||||
|
||||
@ObservedObject
|
||||
@ -28,9 +30,9 @@ struct ItemDetailsView: View {
|
||||
.font(.title3)
|
||||
.padding(.bottom, 5)
|
||||
|
||||
ForEach(viewModel.informationItems, id: \.self.title) { informationItem in
|
||||
ItemDetail(title: informationItem.title, content: informationItem.content)
|
||||
}
|
||||
// ForEach(viewModel.informationItems, id: \.self.title) { informationItem in
|
||||
// ItemDetail(title: informationItem.title, content: informationItem.content)
|
||||
// }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
@ -54,65 +54,61 @@ struct LandscapeItemElement: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ImageView(
|
||||
item.type == .episode && !(inSeasonView ?? false) ? item.getSeriesBackdropImage(maxWidth: 445) : item
|
||||
.getBackdropImage(maxWidth: 445),
|
||||
blurHash: item.type == .episode ? item.getSeriesBackdropImageBlurHash() : item.getBackdropImageBlurHash()
|
||||
)
|
||||
.frame(width: 445, height: 250)
|
||||
.cornerRadius(10)
|
||||
.ignoresSafeArea()
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.userData?.played ?? false {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(.white)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
}
|
||||
}.padding(2)
|
||||
.opacity(1),
|
||||
alignment: .topTrailing
|
||||
).opacity(1)
|
||||
.overlay(ZStack(alignment: .leading) {
|
||||
if focused && item.userData?.playedPercentage != nil {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(
|
||||
gradient: Gradient(colors: [.black, .clear]),
|
||||
startPoint: .bottom,
|
||||
endPoint: .top
|
||||
))
|
||||
.frame(width: 445, height: 90)
|
||||
.mask(CutOffShadow())
|
||||
VStack(alignment: .leading) {
|
||||
Text("CONTINUE • \(item.getItemProgressString() ?? "")")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.offset(y: 5)
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.gray)
|
||||
.opacity(0.4)
|
||||
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.jellyfinPurple)
|
||||
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12)
|
||||
ImageView(item.imageSource(.backdrop, maxWidth: 445))
|
||||
.frame(width: 445, height: 250)
|
||||
.cornerRadius(10)
|
||||
.ignoresSafeArea()
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.userData?.played ?? false {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(.white)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
}
|
||||
}.padding(12)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}, alignment: .bottomLeading)
|
||||
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
||||
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
||||
}.padding(2)
|
||||
.opacity(1),
|
||||
alignment: .topTrailing
|
||||
).opacity(1)
|
||||
.overlay(ZStack(alignment: .leading) {
|
||||
if focused && item.userData?.playedPercentage != nil {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(
|
||||
gradient: Gradient(colors: [.black, .clear]),
|
||||
startPoint: .bottom,
|
||||
endPoint: .top
|
||||
))
|
||||
.frame(width: 445, height: 90)
|
||||
.mask(CutOffShadow())
|
||||
VStack(alignment: .leading) {
|
||||
Text("CONTINUE • \(item.getItemProgressString() ?? "")")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.offset(y: 5)
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.gray)
|
||||
.opacity(0.4)
|
||||
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.jellyfinPurple)
|
||||
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12)
|
||||
}
|
||||
}.padding(12)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}, alignment: .bottomLeading)
|
||||
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
||||
.shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0)
|
||||
if inSeasonView ?? false {
|
||||
Text("\(item.getEpisodeLocator() ?? "") • \(item.name ?? "")")
|
||||
Text("\(item.episodeLocator ?? "") • \(item.name ?? "")")
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.frame(width: 445)
|
||||
} else {
|
||||
Text(item.type == .episode ? "\(item.seriesName ?? "") • \(item.getEpisodeLocator() ?? "")" : item.name ?? "")
|
||||
Text(item.type == .episode ? "\(item.seriesName ?? "") • \(item.episodeLocator ?? "")" : item.name ?? "")
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
@ -123,16 +119,6 @@ struct LandscapeItemElement: View {
|
||||
withAnimation(.linear(duration: 0.15)) {
|
||||
self.focused = envFocus
|
||||
}
|
||||
|
||||
if envFocus == true {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
// your code here
|
||||
if focused == true {
|
||||
backgroundURL = item.getBackdropImage(maxWidth: 1080)
|
||||
BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(focused ? 1.1 : 1)
|
||||
}
|
||||
|
@ -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 SwiftUI
|
||||
|
||||
// TODO: Transition to `PortraitButton`
|
||||
struct PortraitItemElement: View {
|
||||
|
||||
@Environment(\.isFocused)
|
||||
var envFocused: Bool
|
||||
@State
|
||||
@ -21,49 +23,46 @@ struct PortraitItemElement: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ImageView(
|
||||
item.type == .episode ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200),
|
||||
blurHash: item.type == .episode ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()
|
||||
)
|
||||
.frame(width: 200, height: 300)
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: focused ? 10.0 : 0)
|
||||
.shadow(radius: focused ? 10.0 : 0)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.userData?.isFavorite ?? false {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.6)
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundColor(Color(.systemRed))
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
.opacity(1),
|
||||
alignment: .bottomLeading
|
||||
)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.userData?.played ?? false {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(.white)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
} else {
|
||||
if item.userData?.unplayedItemCount != nil {
|
||||
ImageView(item.type == .episode ? item.seriesImageSource(.primary, maxWidth: 200) : item.imageSource(.primary, maxWidth: 200))
|
||||
.frame(width: 200, height: 300)
|
||||
.cornerRadius(10)
|
||||
.shadow(radius: focused ? 10.0 : 0)
|
||||
.shadow(radius: focused ? 10.0 : 0)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.userData?.isFavorite ?? false {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
Text(String(item.userData!.unplayedItemCount ?? 0))
|
||||
.foregroundColor(.white)
|
||||
.font(.caption2)
|
||||
.opacity(0.6)
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundColor(Color(.systemRed))
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
}
|
||||
}.padding(2)
|
||||
.padding(2)
|
||||
.opacity(1),
|
||||
alignment: .topTrailing
|
||||
).opacity(1)
|
||||
alignment: .bottomLeading
|
||||
)
|
||||
.overlay(
|
||||
ZStack {
|
||||
if item.userData?.played ?? false {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(.white)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
} else {
|
||||
if item.userData?.unplayedItemCount != nil {
|
||||
Image(systemName: "circle.fill")
|
||||
.foregroundColor(Color(.systemBlue))
|
||||
Text(String(item.userData!.unplayedItemCount ?? 0))
|
||||
.foregroundColor(.white)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}.padding(2)
|
||||
.opacity(1),
|
||||
alignment: .topTrailing
|
||||
).opacity(1)
|
||||
Text(item.title)
|
||||
.frame(width: 200, height: 30, alignment: .center)
|
||||
if item.type == .movie || item.type == .series {
|
||||
@ -87,16 +86,6 @@ struct PortraitItemElement: View {
|
||||
withAnimation(.linear(duration: 0.15)) {
|
||||
self.focused = envFocus
|
||||
}
|
||||
|
||||
if envFocus == true {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
// your code here
|
||||
if focused == true {
|
||||
backgroundURL = item.getBackdropImage(maxWidth: 1080)
|
||||
BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(focused ? 1.1 : 1)
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
struct AboutView: View {
|
||||
struct AboutAppView: View {
|
||||
|
||||
var body: some View {
|
||||
Text("dud")
|
@ -13,7 +13,7 @@ import SwiftUI
|
||||
struct BasicAppSettingsView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router
|
||||
private var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router
|
||||
@ObservedObject
|
||||
var viewModel: BasicAppSettingsViewModel
|
||||
@State
|
||||
|
@ -12,21 +12,24 @@ import SwiftUI
|
||||
struct ContinueWatchingCard: View {
|
||||
|
||||
@EnvironmentObject
|
||||
var homeRouter: HomeCoordinator.Router
|
||||
private var homeRouter: HomeCoordinator.Router
|
||||
let item: BaseItemDto
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Button {
|
||||
homeRouter.route(to: \.modalItem, item)
|
||||
homeRouter.route(to: \.item, item)
|
||||
} label: {
|
||||
ZStack(alignment: .bottom) {
|
||||
|
||||
if item.itemType == .episode {
|
||||
ImageView(item.getSeriesBackdropImage(maxWidth: 500))
|
||||
.frame(width: 500, height: 281.25)
|
||||
if item.type == .episode {
|
||||
ImageView([
|
||||
item.seriesImageSource(.thumb, maxWidth: 500),
|
||||
item.imageSource(.primary, maxWidth: 500),
|
||||
])
|
||||
.frame(width: 500, height: 281.25)
|
||||
} else {
|
||||
ImageView(item.getBackdropImage(maxWidth: 500))
|
||||
ImageView(item.imageURL(.backdrop, maxWidth: 500))
|
||||
.frame(width: 500, height: 281.25)
|
||||
}
|
||||
|
||||
@ -66,8 +69,8 @@ struct ContinueWatchingCard: View {
|
||||
.lineLimit(1)
|
||||
.frame(width: 500, alignment: .leading)
|
||||
|
||||
if item.itemType == .episode {
|
||||
Text(item.getEpisodeLocator() ?? "")
|
||||
if item.type == .episode {
|
||||
Text(item.episodeLocator ?? "--")
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
|
@ -14,7 +14,7 @@ import SwiftUI
|
||||
struct ContinueWatchingView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
var homeRouter: HomeCoordinator.Router
|
||||
private var homeRouter: HomeCoordinator.Router
|
||||
let items: [BaseItemDto]
|
||||
|
||||
var body: some View {
|
||||
|
@ -8,20 +8,16 @@
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Introspect
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
var homeRouter: HomeCoordinator.Router
|
||||
@StateObject
|
||||
var viewModel = HomeViewModel()
|
||||
@Default(.showPosterLabels)
|
||||
var showPosterLabels
|
||||
|
||||
@State
|
||||
var showingSettings = false
|
||||
private var router: HomeCoordinator.Router
|
||||
@ObservedObject
|
||||
var viewModel: HomeViewModel
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isLoading {
|
||||
@ -39,8 +35,12 @@ struct HomeView: View {
|
||||
)
|
||||
|
||||
if !viewModel.nextUpItems.isEmpty {
|
||||
NextUpView(items: viewModel.nextUpItems)
|
||||
.focusSection()
|
||||
PortraitPosterHStack(
|
||||
title: L10n.nextUp,
|
||||
items: viewModel.nextUpItems
|
||||
) { item in
|
||||
router.route(to: \.item, item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HomeCinematicView(
|
||||
@ -49,38 +49,27 @@ struct HomeView: View {
|
||||
)
|
||||
|
||||
if !viewModel.nextUpItems.isEmpty {
|
||||
NextUpView(items: viewModel.nextUpItems)
|
||||
.focusSection()
|
||||
PortraitPosterHStack(
|
||||
title: L10n.nextUp,
|
||||
items: viewModel.nextUpItems
|
||||
) { item in
|
||||
router.route(to: \.item, item)
|
||||
}
|
||||
}
|
||||
|
||||
PortraitItemsRowView(
|
||||
rowTitle: L10n.recentlyAdded,
|
||||
items: viewModel.latestAddedItems,
|
||||
showItemTitles: showPosterLabels
|
||||
) { item in
|
||||
homeRouter.route(to: \.modalItem, item)
|
||||
if !viewModel.latestAddedItems.isEmpty {
|
||||
PortraitPosterHStack(
|
||||
title: L10n.recentlyAdded,
|
||||
items: viewModel.latestAddedItems
|
||||
) { item in
|
||||
router.route(to: \.item, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(viewModel.libraries, id: \.self) { library in
|
||||
LatestMediaView(viewModel: LatestMediaViewModel(library: library))
|
||||
.focusSection()
|
||||
LatestInLibraryView(viewModel: LatestMediaViewModel(library: library))
|
||||
}
|
||||
|
||||
Spacer(minLength: 100)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
viewModel.refresh()
|
||||
} label: {
|
||||
L10n.refresh.text
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.focusSection()
|
||||
}
|
||||
}
|
||||
.edgesIgnoringSafeArea(.top)
|
||||
|
@ -46,28 +46,24 @@ struct CinematicCollectionItemView: View {
|
||||
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
|
||||
CinematicItemAboutView(viewModel: viewModel)
|
||||
|
||||
PortraitItemsRowView(
|
||||
rowTitle: L10n.items,
|
||||
items: viewModel.collectionItems
|
||||
) { item in
|
||||
itemRouter.route(to: \.item, item)
|
||||
}
|
||||
|
||||
if !viewModel.similarItems.isEmpty {
|
||||
PortraitItemsRowView(
|
||||
rowTitle: L10n.recommended,
|
||||
items: viewModel.similarItems,
|
||||
showItemTitles: showPosterLabels
|
||||
) { item in
|
||||
itemRouter.route(to: \.item, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 50)
|
||||
// VStack(alignment: .leading, spacing: 20) {
|
||||
//
|
||||
// CinematicItemAboutView(viewModel: viewModel)
|
||||
//
|
||||
// PortraitImageHStack(rowTitle: L10n.items,
|
||||
// items: viewModel.collectionItems) { item in
|
||||
// itemRouter.route(to: \.item, item)
|
||||
// }
|
||||
//
|
||||
// if !viewModel.similarItems.isEmpty {
|
||||
// PortraitImageHStack(rowTitle: L10n.recommended,
|
||||
// items: viewModel.similarItems,
|
||||
// showItemTitles: showPosterLabels) { item in
|
||||
// itemRouter.route(to: \.item, item)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .padding(.vertical, 50)
|
||||
}
|
||||
}
|
||||
}
|
@ -22,7 +22,7 @@ struct CinematicEpisodeItemView: View {
|
||||
var showPosterLabels
|
||||
|
||||
func generateSubtitle() -> String? {
|
||||
guard let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() else {
|
||||
guard let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.episodeLocator else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -60,27 +60,23 @@ struct CinematicEpisodeItemView: View {
|
||||
|
||||
CinematicItemAboutView(viewModel: viewModel)
|
||||
|
||||
EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true)
|
||||
.focusSection()
|
||||
// EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true)
|
||||
// .focusSection()
|
||||
|
||||
if let seriesItem = viewModel.series {
|
||||
PortraitItemsRowView(
|
||||
rowTitle: L10n.series,
|
||||
items: [seriesItem]
|
||||
) { seriesItem in
|
||||
itemRouter.route(to: \.item, seriesItem)
|
||||
}
|
||||
}
|
||||
// if let seriesItem = viewModel.series {
|
||||
// PortraitItemsRowView(rowTitle: L10n.series,
|
||||
// items: [seriesItem]) { seriesItem in
|
||||
// itemRouter.route(to: \.item, seriesItem)
|
||||
// }
|
||||
// }
|
||||
|
||||
if !viewModel.similarItems.isEmpty {
|
||||
PortraitItemsRowView(
|
||||
rowTitle: L10n.recommended,
|
||||
items: viewModel.similarItems,
|
||||
showItemTitles: showPosterLabels
|
||||
) { item in
|
||||
itemRouter.route(to: \.item, item)
|
||||
}
|
||||
}
|
||||
// if !viewModel.similarItems.isEmpty {
|
||||
// PortraitImageHStack(rowTitle: L10n.recommended,
|
||||
// items: viewModel.similarItems,
|
||||
// showItemTitles: showPosterLabels) { item in
|
||||
// itemRouter.route(to: \.item, item)
|
||||
// }
|
||||
// }
|
||||
|
||||
ItemDetailsView(viewModel: viewModel)
|
||||
}
|
@ -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