mirror of
https://github.com/jellyfin/Swiftfin.git
synced 2024-11-27 00:00:37 +00:00
Refactor PosterButton
and libraries, good UICollectionView
s, proper orientation handling, and more (#905)
This commit is contained in:
parent
11cc5f56ac
commit
a645444f25
7
PreferencesView/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
PreferencesView/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
14
PreferencesView/Package.resolved
Normal file
14
PreferencesView/Package.resolved
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swizzleswift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/MarioIannotta/SwizzleSwift",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "e2d31c646182bf94a496b173c6ee5ad191230e9a"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
26
PreferencesView/Package.swift
Normal file
26
PreferencesView/Package.swift
Normal file
@ -0,0 +1,26 @@
|
||||
// swift-tools-version: 5.9
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "PreferencesView",
|
||||
platforms: [
|
||||
.iOS(.v15),
|
||||
.tvOS(.v15),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "PreferencesView",
|
||||
targets: ["PreferencesView"]
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/MarioIannotta/SwizzleSwift", branch: "master"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "PreferencesView",
|
||||
dependencies: [.product(name: "SwizzleSwift", package: "SwizzleSwift")]
|
||||
),
|
||||
]
|
||||
)
|
1
PreferencesView/README.md
Normal file
1
PreferencesView/README.md
Normal file
@ -0,0 +1 @@
|
||||
# PreferencesView
|
@ -6,9 +6,9 @@
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
class Box {
|
||||
|
||||
enum SelectorType {
|
||||
case single
|
||||
case multi
|
||||
weak var value: UIPreferencesHostingController?
|
||||
|
||||
init() {}
|
||||
}
|
@ -6,23 +6,25 @@
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct KeyCommandAction {
|
||||
public struct KeyCommandAction {
|
||||
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let input: String
|
||||
let modifierFlags: UIKeyModifierFlags
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
input: String,
|
||||
modifierFlags: UIKeyModifierFlags = [],
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.input = input
|
||||
self.modifierFlags = modifierFlags
|
||||
self.action = action
|
||||
@ -31,7 +33,7 @@ struct KeyCommandAction {
|
||||
|
||||
extension KeyCommandAction: Equatable {
|
||||
|
||||
static func == (lhs: KeyCommandAction, rhs: KeyCommandAction) -> Bool {
|
||||
public static func == (lhs: KeyCommandAction, rhs: KeyCommandAction) -> Bool {
|
||||
lhs.input == rhs.input
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@resultBuilder
|
||||
public enum KeyCommandsBuilder {
|
||||
|
||||
public static func buildBlock(_ components: [KeyCommandAction]...) -> [KeyCommandAction] {
|
||||
components.flatMap { $0 }
|
||||
}
|
||||
|
||||
public static func buildExpression(_ expression: KeyCommandAction) -> [KeyCommandAction] {
|
||||
[expression]
|
||||
}
|
||||
|
||||
public static func buildOptional(_ component: [KeyCommandAction]?) -> [KeyCommandAction] {
|
||||
component ?? []
|
||||
}
|
||||
|
||||
public static func buildEither(first component: [KeyCommandAction]) -> [KeyCommandAction] {
|
||||
component
|
||||
}
|
||||
|
||||
public static func buildEither(second component: [KeyCommandAction]) -> [KeyCommandAction] {
|
||||
component
|
||||
}
|
||||
|
||||
public static func buildArray(_ components: [[KeyCommandAction]]) -> [KeyCommandAction] {
|
||||
components.flatMap { $0 }
|
||||
}
|
||||
}
|
43
PreferencesView/Sources/PreferencesView/PreferenceKeys.swift
Normal file
43
PreferencesView/Sources/PreferencesView/PreferenceKeys.swift
Normal 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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
struct KeyCommandsPreferenceKey: PreferenceKey {
|
||||
|
||||
static var defaultValue: [KeyCommandAction] = []
|
||||
|
||||
static func reduce(value: inout [KeyCommandAction], nextValue: () -> [KeyCommandAction]) {
|
||||
value.append(contentsOf: nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
struct PreferredScreenEdgesDeferringSystemGesturesPreferenceKey: PreferenceKey {
|
||||
|
||||
static var defaultValue: UIRectEdge = [.left, .right]
|
||||
|
||||
static func reduce(value: inout UIRectEdge, nextValue: () -> UIRectEdge) {}
|
||||
}
|
||||
|
||||
struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey {
|
||||
|
||||
static var defaultValue: Bool = false
|
||||
|
||||
static func reduce(value: inout Bool, nextValue: () -> Bool) {
|
||||
value = nextValue() || value
|
||||
}
|
||||
}
|
||||
|
||||
struct SupportedOrientationsPreferenceKey: PreferenceKey {
|
||||
|
||||
static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown
|
||||
|
||||
static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) {}
|
||||
}
|
||||
#endif
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwizzleSwift
|
||||
|
||||
public struct PreferencesView<Content: View>: UIViewControllerRepresentable {
|
||||
|
||||
private var content: () -> Content
|
||||
|
||||
public init(@ViewBuilder content: @escaping () -> Content) {
|
||||
_ = UIViewController.swizzlePreferences
|
||||
self.content = content
|
||||
}
|
||||
|
||||
public func makeUIViewController(context: Context) -> UIPreferencesHostingController {
|
||||
UIPreferencesHostingController(content: content)
|
||||
}
|
||||
|
||||
public func updateUIViewController(_ uiViewController: UIPreferencesHostingController, context: Context) {}
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public class UIPreferencesHostingController: UIHostingController<AnyView> {
|
||||
|
||||
init<Content: View>(@ViewBuilder content: @escaping () -> Content) {
|
||||
let box = Box()
|
||||
let rootView = AnyView(
|
||||
content()
|
||||
#if os(iOS)
|
||||
.onPreferenceChange(KeyCommandsPreferenceKey.self) {
|
||||
box.value?._keyCommandActions = $0
|
||||
}
|
||||
.onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) {
|
||||
box.value?._prefersHomeIndicatorAutoHidden = $0
|
||||
}
|
||||
.onPreferenceChange(PreferredScreenEdgesDeferringSystemGesturesPreferenceKey.self) {
|
||||
box.value?._preferredScreenEdgesDeferringSystemGestures = $0
|
||||
}
|
||||
.onPreferenceChange(SupportedOrientationsPreferenceKey.self) {
|
||||
box.value?._orientations = $0
|
||||
}
|
||||
#endif
|
||||
)
|
||||
|
||||
super.init(rootView: rootView)
|
||||
|
||||
box.value = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
// MARK: Key Commands
|
||||
|
||||
private var _keyCommandActions: [KeyCommandAction] = [] {
|
||||
willSet {
|
||||
_keyCommands = newValue.map { action in
|
||||
let keyCommand = UIKeyCommand(
|
||||
title: action.title,
|
||||
action: #selector(keyCommandHit),
|
||||
input: String(action.input),
|
||||
modifierFlags: action.modifierFlags
|
||||
)
|
||||
|
||||
keyCommand.subtitle = action.subtitle
|
||||
keyCommand.wantsPriorityOverSystemBehavior = true
|
||||
|
||||
return keyCommand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var _keyCommands: [UIKeyCommand] = []
|
||||
|
||||
override public var keyCommands: [UIKeyCommand]? {
|
||||
_keyCommands
|
||||
}
|
||||
|
||||
@objc
|
||||
private func keyCommandHit(keyCommand: UIKeyCommand) {
|
||||
guard let action = _keyCommandActions
|
||||
.first(where: { $0.input == keyCommand.input && $0.modifierFlags == keyCommand.modifierFlags }) else { return }
|
||||
action.action()
|
||||
}
|
||||
|
||||
// MARK: Orientation
|
||||
|
||||
var _orientations: UIInterfaceOrientationMask = .all {
|
||||
didSet {
|
||||
if #available(iOS 16, *) {
|
||||
setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
_orientations
|
||||
}
|
||||
|
||||
// MARK: Defer Edges
|
||||
|
||||
private var _preferredScreenEdgesDeferringSystemGestures: UIRectEdge = [.left, .right] {
|
||||
didSet { setNeedsUpdateOfScreenEdgesDeferringSystemGestures() }
|
||||
}
|
||||
|
||||
override public var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
||||
_preferredScreenEdgesDeferringSystemGestures
|
||||
}
|
||||
|
||||
// MARK: Home Indicator Auto Hidden
|
||||
|
||||
private var _prefersHomeIndicatorAutoHidden = false {
|
||||
didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() }
|
||||
}
|
||||
|
||||
override public var prefersHomeIndicatorAutoHidden: Bool {
|
||||
_prefersHomeIndicatorAutoHidden
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwizzleSwift
|
||||
import UIKit
|
||||
|
||||
extension UIViewController {
|
||||
|
||||
// MARK: Swizzle
|
||||
|
||||
// only swizzle once
|
||||
static var swizzlePreferences = {
|
||||
Swizzle(UIViewController.self) {
|
||||
#if os(iOS)
|
||||
#selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures)
|
||||
#selector(getter: supportedInterfaceOrientations) <-> #selector(swizzled_supportedInterfaceOrientations)
|
||||
#endif
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: Swizzles
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
@objc
|
||||
func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? {
|
||||
if self is UIPreferencesHostingController {
|
||||
return nil
|
||||
} else {
|
||||
return search()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? {
|
||||
if self is UIPreferencesHostingController {
|
||||
return nil
|
||||
} else {
|
||||
return search()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func swizzled_prefersHomeIndicatorAutoHidden() -> Bool {
|
||||
search()?.prefersHomeIndicatorAutoHidden ?? false
|
||||
}
|
||||
|
||||
@objc
|
||||
func swizzled_supportedInterfaceOrientations() -> UIInterfaceOrientationMask {
|
||||
search()?._orientations ?? .all
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: Search
|
||||
|
||||
private func search() -> UIPreferencesHostingController? {
|
||||
if let result = children.compactMap({ $0 as? UIPreferencesHostingController }).first {
|
||||
return result
|
||||
}
|
||||
|
||||
for child in children {
|
||||
if let result = child.search() {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
30
PreferencesView/Sources/PreferencesView/ViewExtensions.swift
Normal file
30
PreferencesView/Sources/PreferencesView/ViewExtensions.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
|
||||
#if os(iOS)
|
||||
func keyCommands(@KeyCommandsBuilder _ commands: @escaping () -> [KeyCommandAction]) -> some View {
|
||||
preference(key: KeyCommandsPreferenceKey.self, value: commands())
|
||||
}
|
||||
|
||||
func preferredScreenEdgesDeferringSystemGestures(_ edges: UIRectEdge) -> some View {
|
||||
preference(key: PreferredScreenEdgesDeferringSystemGesturesPreferenceKey.self, value: edges)
|
||||
}
|
||||
|
||||
func prefersHomeIndicatorAutoHidden(_ hidden: Bool) -> some View {
|
||||
preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: hidden)
|
||||
}
|
||||
|
||||
func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View {
|
||||
preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations)
|
||||
}
|
||||
#endif
|
||||
}
|
24
Shared/Components/AssertionFailureView.swift
Normal file
24
Shared/Components/AssertionFailureView.swift
Normal file
@ -0,0 +1,24 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AssertionFailureView: View {
|
||||
|
||||
let message: String
|
||||
|
||||
init(_ message: String) {
|
||||
self.message = message
|
||||
|
||||
assertionFailure(message)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
@ -13,47 +13,37 @@ import NukeUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ImageSource: Hashable {
|
||||
|
||||
let url: URL?
|
||||
let blurHash: String?
|
||||
|
||||
init(url: URL? = nil, blurHash: String? = nil) {
|
||||
self.url = url
|
||||
self.blurHash = blurHash
|
||||
}
|
||||
}
|
||||
private let imagePipeline = ImagePipeline(configuration: .withDataCache)
|
||||
|
||||
// TODO: Binding inits?
|
||||
// - instead of removing first source on failure, just safe index into sources
|
||||
struct ImageView: View {
|
||||
|
||||
@State
|
||||
private var sources: [ImageSource]
|
||||
|
||||
private var image: (NukeUI.Image) -> any View
|
||||
private var image: (Image) -> any View
|
||||
private var placeholder: (() -> any View)?
|
||||
private var failure: () -> any View
|
||||
private var resizingMode: ImageResizingMode
|
||||
|
||||
@ViewBuilder
|
||||
private func _placeholder(_ currentSource: ImageSource) -> some View {
|
||||
if let placeholder = placeholder {
|
||||
placeholder()
|
||||
.eraseToAnyView()
|
||||
} else if let blurHash = currentSource.blurHash {
|
||||
BlurHashView(blurHash: blurHash, size: .Square(length: 16))
|
||||
} else {
|
||||
DefaultPlaceholderView()
|
||||
DefaultPlaceholderView(blurHash: currentSource.blurHash)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let currentSource = sources.first {
|
||||
LazyImage(url: currentSource.url) { state in
|
||||
LazyImage(url: currentSource.url, transaction: .init(animation: .linear)) { state in
|
||||
if state.isLoading {
|
||||
_placeholder(currentSource)
|
||||
} else if let _image = state.image {
|
||||
image(_image.resizingMode(resizingMode))
|
||||
.eraseToAnyView()
|
||||
_image
|
||||
.resizable()
|
||||
} else if state.error != nil {
|
||||
failure()
|
||||
.eraseToAnyView()
|
||||
@ -62,8 +52,7 @@ struct ImageView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.pipeline(ImagePipeline(configuration: .withDataCache))
|
||||
.id(currentSource)
|
||||
.pipeline(imagePipeline)
|
||||
} else {
|
||||
failure()
|
||||
.eraseToAnyView()
|
||||
@ -72,43 +61,44 @@ struct ImageView: View {
|
||||
}
|
||||
|
||||
extension ImageView {
|
||||
|
||||
init(_ source: ImageSource) {
|
||||
self.init(
|
||||
sources: [source],
|
||||
sources: [source].compacted(using: \.url),
|
||||
image: { $0 },
|
||||
placeholder: nil,
|
||||
failure: { DefaultFailureView() },
|
||||
resizingMode: .aspectFill
|
||||
failure: { DefaultFailureView() }
|
||||
)
|
||||
}
|
||||
|
||||
init(_ sources: [ImageSource]) {
|
||||
self.init(
|
||||
sources: sources,
|
||||
sources: sources.compacted(using: \.url),
|
||||
image: { $0 },
|
||||
placeholder: nil,
|
||||
failure: { DefaultFailureView() },
|
||||
resizingMode: .aspectFill
|
||||
failure: { DefaultFailureView() }
|
||||
)
|
||||
}
|
||||
|
||||
init(_ source: URL?) {
|
||||
self.init(
|
||||
sources: [ImageSource(url: source, blurHash: nil)],
|
||||
sources: [ImageSource(url: source)],
|
||||
image: { $0 },
|
||||
placeholder: nil,
|
||||
failure: { DefaultFailureView() },
|
||||
resizingMode: .aspectFill
|
||||
failure: { DefaultFailureView() }
|
||||
)
|
||||
}
|
||||
|
||||
init(_ sources: [URL?]) {
|
||||
let imageSources = sources
|
||||
.compactMap { $0 }
|
||||
.map { ImageSource(url: $0) }
|
||||
|
||||
self.init(
|
||||
sources: sources.map { ImageSource(url: $0, blurHash: nil) },
|
||||
sources: imageSources,
|
||||
image: { $0 },
|
||||
placeholder: nil,
|
||||
failure: { DefaultFailureView() },
|
||||
resizingMode: .aspectFill
|
||||
failure: { DefaultFailureView() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -117,7 +107,7 @@ extension ImageView {
|
||||
|
||||
extension ImageView {
|
||||
|
||||
func image(@ViewBuilder _ content: @escaping (NukeUI.Image) -> any View) -> Self {
|
||||
func image(@ViewBuilder _ content: @escaping (Image) -> any View) -> Self {
|
||||
copy(modifying: \.image, with: content)
|
||||
}
|
||||
|
||||
@ -128,10 +118,6 @@ extension ImageView {
|
||||
func failure(@ViewBuilder _ content: @escaping () -> any View) -> Self {
|
||||
copy(modifying: \.failure, with: content)
|
||||
}
|
||||
|
||||
func resizingMode(_ resizingMode: ImageResizingMode) -> Self {
|
||||
copy(modifying: \.resizingMode, with: resizingMode)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Defaults
|
||||
@ -147,9 +133,14 @@ extension ImageView {
|
||||
|
||||
struct DefaultPlaceholderView: View {
|
||||
|
||||
let blurHash: String?
|
||||
|
||||
var body: some View {
|
||||
Color.secondarySystemFill
|
||||
.opacity(0.5)
|
||||
if let blurHash {
|
||||
BlurHashView(blurHash: blurHash, size: .Square(length: 8))
|
||||
} else {
|
||||
Color.secondarySystemFill
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,30 +0,0 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct InitialFailureView: View {
|
||||
|
||||
let initials: String
|
||||
|
||||
init(_ initials: String) {
|
||||
self.initials = initials
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.secondarySystemFill
|
||||
.opacity(0.5)
|
||||
|
||||
Text(initials)
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
38
Shared/Components/MaxHeightText.swift
Normal file
38
Shared/Components/MaxHeightText.swift
Normal file
@ -0,0 +1,38 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// TODO: anchor for scaleEffect?
|
||||
// TODO: try an implementation that doesn't require passing in the height
|
||||
|
||||
/// A `Text` wrapper that will scale down the underlying `Text` view
|
||||
/// if the height is greater than the given `maxHeight`.
|
||||
struct MaxHeightText: View {
|
||||
|
||||
@State
|
||||
private var scale = 1.0
|
||||
|
||||
let text: String
|
||||
let maxHeight: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.hidden()
|
||||
.overlay {
|
||||
Text(text)
|
||||
.scaleEffect(CGSize(width: scale, height: scale), anchor: .bottom)
|
||||
}
|
||||
.onSizeChanged { newSize in
|
||||
if newSize.height > maxHeight {
|
||||
scale = maxHeight / newSize.height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -19,7 +19,7 @@ struct WatchedIndicator: View {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.resizable()
|
||||
.frame(width: size, height: size)
|
||||
.accentSymbolRendering(accentColor: .white)
|
||||
.paletteOverlayRendering(color: .white)
|
||||
.padding(3)
|
||||
}
|
||||
}
|
||||
|
@ -8,11 +8,11 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct Divider: View {
|
||||
struct RowDivider: View {
|
||||
|
||||
var body: some View {
|
||||
Color.secondarySystemFill
|
||||
.frame(height: 0.5)
|
||||
.padding(.horizontal)
|
||||
.frame(height: 1)
|
||||
.edgePadding(.horizontal)
|
||||
}
|
||||
}
|
@ -6,85 +6,106 @@
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
// TODO: Implement different behavior types, where selected/unselected
|
||||
// items can appear in different sections
|
||||
// TODO: Label generic not really necessary if just restricting to `Text`
|
||||
// - go back to `any View` implementation instead
|
||||
|
||||
struct SelectorView<Item: Displayable & Identifiable>: View {
|
||||
enum SelectorType {
|
||||
case single
|
||||
case multi
|
||||
}
|
||||
|
||||
@Default(.accentColor)
|
||||
struct SelectorView<Element: Displayable & Hashable, Label: View>: View {
|
||||
|
||||
@Environment(\.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@Binding
|
||||
private var selection: [Item]
|
||||
private var selection: Set<Element>
|
||||
|
||||
private let allItems: [Item]
|
||||
private var label: (Item) -> any View
|
||||
private let sources: [Element]
|
||||
private var label: (Element) -> Label
|
||||
private let type: SelectorType
|
||||
|
||||
var body: some View {
|
||||
List(allItems) { item in
|
||||
List(sources, id: \.hashValue) { element in
|
||||
Button {
|
||||
switch type {
|
||||
case .single:
|
||||
handleSingleSelect(with: item)
|
||||
handleSingleSelect(with: element)
|
||||
case .multi:
|
||||
handleMultiSelect(with: item)
|
||||
handleMultiSelect(with: element)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
label(item).eraseToAnyView()
|
||||
label(element)
|
||||
|
||||
Spacer()
|
||||
|
||||
if selection.contains(where: { $0.id == item.id }) {
|
||||
if selection.contains(element) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.accentSymbolRendering()
|
||||
.paletteOverlayRendering()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSingleSelect(with item: Item) {
|
||||
selection = [item]
|
||||
private func handleSingleSelect(with element: Element) {
|
||||
selection = [element]
|
||||
}
|
||||
|
||||
private func handleMultiSelect(with item: Item) {
|
||||
if selection.contains(where: { $0.id == item.id }) {
|
||||
selection.removeAll(where: { $0.id == item.id })
|
||||
private func handleMultiSelect(with element: Element) {
|
||||
if selection.contains(element) {
|
||||
selection.remove(element)
|
||||
} else {
|
||||
selection.append(item)
|
||||
selection.insert(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SelectorView {
|
||||
extension SelectorView where Label == Text {
|
||||
|
||||
init(selection: Binding<[Element]>, sources: [Element], type: SelectorType) {
|
||||
|
||||
let selectionBinding = Binding {
|
||||
Set(selection.wrappedValue)
|
||||
} set: { newValue in
|
||||
selection.wrappedValue = sources.intersection(newValue)
|
||||
}
|
||||
|
||||
init(selection: Binding<[Item]>, allItems: [Item], type: SelectorType) {
|
||||
self.init(
|
||||
selection: selection,
|
||||
allItems: allItems,
|
||||
selection: selectionBinding,
|
||||
sources: sources,
|
||||
label: { Text($0.displayTitle).foregroundColor(.primary) },
|
||||
type: type
|
||||
)
|
||||
}
|
||||
|
||||
init(selection: Binding<Item>, allItems: [Item]) {
|
||||
init(selection: Binding<Element>, sources: [Element]) {
|
||||
|
||||
let selectionBinding = Binding {
|
||||
Set([selection.wrappedValue])
|
||||
} set: { newValue in
|
||||
selection.wrappedValue = newValue.first!
|
||||
}
|
||||
|
||||
self.init(
|
||||
selection: .init(get: { [selection.wrappedValue] }, set: { selection.wrappedValue = $0[0] }),
|
||||
allItems: allItems,
|
||||
selection: selectionBinding,
|
||||
sources: sources,
|
||||
label: { Text($0.displayTitle).foregroundColor(.primary) },
|
||||
type: .single
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func label(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self {
|
||||
extension SelectorView {
|
||||
|
||||
func label(@ViewBuilder _ content: @escaping (Element) -> Label) -> Self {
|
||||
copy(modifying: \.label, with: content)
|
||||
}
|
||||
}
|
||||
|
@ -10,25 +10,28 @@ import SwiftUI
|
||||
|
||||
// https://movingparts.io/variadic-views-in-swiftui
|
||||
|
||||
struct SeparatorHStack: View {
|
||||
/// An `HStack` that inserts an optional `separator` between views.
|
||||
///
|
||||
/// - Note: Default spacing is removed. The separator view is responsible
|
||||
/// for spacing.
|
||||
struct SeparatorHStack<Content: View>: View {
|
||||
|
||||
private var content: () -> any View
|
||||
private var content: () -> Content
|
||||
private var separator: () -> any View
|
||||
|
||||
var body: some View {
|
||||
_VariadicView.Tree(SeparatorHStackLayout(separator: separator)) {
|
||||
content()
|
||||
.eraseToAnyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SeparatorHStack {
|
||||
|
||||
init(@ViewBuilder _ content: @escaping () -> any View) {
|
||||
init(@ViewBuilder _ content: @escaping () -> Content) {
|
||||
self.init(
|
||||
content: content,
|
||||
separator: { Divider() }
|
||||
separator: { RowDivider() }
|
||||
)
|
||||
}
|
||||
|
||||
@ -37,37 +40,34 @@ extension SeparatorHStack {
|
||||
}
|
||||
}
|
||||
|
||||
struct SeparatorHStackLayout: _VariadicView_UnaryViewRoot {
|
||||
extension SeparatorHStack {
|
||||
|
||||
var separator: () -> any View
|
||||
struct SeparatorHStackLayout: _VariadicView_UnaryViewRoot {
|
||||
|
||||
@ViewBuilder
|
||||
func body(children: _VariadicView.Children) -> some View {
|
||||
var separator: () -> any View
|
||||
|
||||
let last = children.last?.id
|
||||
@ViewBuilder
|
||||
func body(children: _VariadicView.Children) -> some View {
|
||||
|
||||
localHStack {
|
||||
ForEach(children) { child in
|
||||
child
|
||||
let last = children.last?.id
|
||||
|
||||
if child.id != last {
|
||||
separator()
|
||||
.eraseToAnyView()
|
||||
localHStack {
|
||||
ForEach(children) { child in
|
||||
child
|
||||
|
||||
if child.id != last {
|
||||
separator()
|
||||
.eraseToAnyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func localHStack(@ViewBuilder content: @escaping () -> some View) -> some View {
|
||||
#if os(tvOS)
|
||||
HStack(spacing: 0) {
|
||||
content()
|
||||
@ViewBuilder
|
||||
private func localHStack(@ViewBuilder content: @escaping () -> some View) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
#else
|
||||
HStack {
|
||||
content()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,9 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// TODO: steal from SwiftUI, rename to something like
|
||||
// `LabeledContentView` with `label` and `value`
|
||||
|
||||
struct TextPairView: View {
|
||||
|
||||
let leading: String
|
||||
@ -29,7 +32,7 @@ extension TextPairView {
|
||||
|
||||
init(_ textPair: TextPair) {
|
||||
self.init(
|
||||
leading: textPair.displayTitle,
|
||||
leading: textPair.title,
|
||||
trailing: textPair.subtitle
|
||||
)
|
||||
}
|
||||
|
@ -9,21 +9,30 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
// TODO: only allow `view` selection when truncated?
|
||||
|
||||
struct TruncatedText: View {
|
||||
|
||||
@Default(.accentColor)
|
||||
enum SeeMoreType {
|
||||
case button
|
||||
case view
|
||||
}
|
||||
|
||||
@Environment(\.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@State
|
||||
private var isTruncated: Bool = false
|
||||
@State
|
||||
private var fullSize: CGFloat = 0
|
||||
private var fullheight: CGFloat = 0
|
||||
|
||||
private var isTruncatedBinding: Binding<Bool>
|
||||
private var onSeeMore: () -> Void
|
||||
private let seeMoreText = "\u{2026} See More"
|
||||
private var seeMoreType: SeeMoreType
|
||||
private let text: String
|
||||
private var seeMoreAction: () -> Void
|
||||
private let seeMoreText = "... See More"
|
||||
|
||||
var body: some View {
|
||||
private var textView: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Text(text)
|
||||
.inverseMask(alignment: .bottomTrailing) {
|
||||
@ -54,9 +63,14 @@ struct TruncatedText: View {
|
||||
Text(seeMoreText)
|
||||
.foregroundColor(accentColor)
|
||||
#else
|
||||
Button {
|
||||
seeMoreAction()
|
||||
} label: {
|
||||
if seeMoreType == .button {
|
||||
Button {
|
||||
onSeeMore()
|
||||
} label: {
|
||||
Text(seeMoreText)
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
} else {
|
||||
Text(seeMoreText)
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
@ -66,16 +80,11 @@ struct TruncatedText: View {
|
||||
.background {
|
||||
ZStack {
|
||||
if !isTruncated {
|
||||
if fullSize != 0 {
|
||||
if fullheight != 0 {
|
||||
Text(text)
|
||||
.background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
if fullSize > proxy.size.height {
|
||||
self.isTruncated = true
|
||||
}
|
||||
}
|
||||
.onSizeChanged { newSize in
|
||||
if fullheight > newSize.height {
|
||||
isTruncated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -83,18 +92,29 @@ struct TruncatedText: View {
|
||||
Text(text)
|
||||
.lineLimit(10)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
self.fullSize = proxy.size.height
|
||||
}
|
||||
}
|
||||
.onSizeChanged { newSize in
|
||||
fullheight = newSize.height
|
||||
}
|
||||
}
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
.onChange(of: isTruncated) { newValue in
|
||||
isTruncatedBinding.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if seeMoreType == .button {
|
||||
textView
|
||||
} else {
|
||||
Button {
|
||||
onSeeMore()
|
||||
} label: {
|
||||
textView
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,12 +122,22 @@ extension TruncatedText {
|
||||
|
||||
init(_ text: String) {
|
||||
self.init(
|
||||
text: text,
|
||||
seeMoreAction: {}
|
||||
isTruncatedBinding: .constant(false),
|
||||
onSeeMore: {},
|
||||
seeMoreType: .button,
|
||||
text: text
|
||||
)
|
||||
}
|
||||
|
||||
func seeMoreAction(_ action: @escaping () -> Void) -> Self {
|
||||
copy(modifying: \.seeMoreAction, with: action)
|
||||
func isTruncated(_ isTruncated: Binding<Bool>) -> Self {
|
||||
copy(modifying: \.isTruncatedBinding, with: isTruncated)
|
||||
}
|
||||
|
||||
func onSeeMore(_ action: @escaping () -> Void) -> Self {
|
||||
copy(modifying: \.onSeeMore, with: action)
|
||||
}
|
||||
|
||||
func seeMoreType(_ type: SeeMoreType) -> Self {
|
||||
copy(modifying: \.seeMoreType, with: type)
|
||||
}
|
||||
}
|
||||
|
34
Shared/Components/TypeSystemNameView.swift
Normal file
34
Shared/Components/TypeSystemNameView.swift
Normal file
@ -0,0 +1,34 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TypeSystemNameView<Item: Poster>: View {
|
||||
|
||||
@State
|
||||
private var contentSize: CGSize = .zero
|
||||
|
||||
let item: Item
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.secondarySystemFill
|
||||
.opacity(0.5)
|
||||
|
||||
if let typeSystemImage = item.typeSystemName {
|
||||
Image(systemName: typeSystemImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
.frame(width: contentSize.width / 3.5, height: contentSize.height / 3)
|
||||
}
|
||||
}
|
||||
.size($contentSize)
|
||||
}
|
||||
}
|
@ -8,12 +8,12 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WrappedView: View {
|
||||
struct WrappedView<Content: View>: View {
|
||||
|
||||
let content: () -> any View
|
||||
@ViewBuilder
|
||||
let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.eraseToAnyView()
|
||||
}
|
||||
}
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
// TODO: See if this and LibraryCoordinator can be merged,
|
||||
// along with all corresponding views
|
||||
final class BasicLibraryCoordinator: NavigationCoordinatable {
|
||||
|
||||
struct Parameters {
|
||||
let title: String?
|
||||
let viewModel: PagingLibraryViewModel
|
||||
}
|
||||
|
||||
let stack = NavigationStack(initial: \BasicLibraryCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
#if os(iOS)
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
@Route(.modal)
|
||||
var item = makeItem
|
||||
@Route(.modal)
|
||||
var library = makeLibrary
|
||||
#endif
|
||||
|
||||
private let parameters: Parameters
|
||||
|
||||
init(parameters: Parameters) {
|
||||
self.parameters = parameters
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
BasicLibraryView(viewModel: parameters.viewModel)
|
||||
#if os(iOS)
|
||||
.if(parameters.title != nil) { view in
|
||||
view.navigationTitle(parameters.title ?? .emptyDash)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func makeItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
|
||||
NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||
}
|
||||
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator<LibraryCoordinator> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(parameters: parameters))
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class CastAndCrewLibraryCoordinator: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \CastAndCrewLibraryCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
|
||||
let people: [BaseItemPerson]
|
||||
|
||||
init(people: [BaseItemPerson]) {
|
||||
self.people = people
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
CastAndCrewLibraryView(people: people)
|
||||
}
|
||||
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator {
|
||||
LibraryCoordinator(parameters: parameters)
|
||||
}
|
||||
}
|
@ -14,10 +14,8 @@ import SwiftUI
|
||||
final class FilterCoordinator: NavigationCoordinatable {
|
||||
|
||||
struct Parameters {
|
||||
let title: String
|
||||
let type: ItemFilterType
|
||||
let viewModel: FilterViewModel
|
||||
let filter: WritableKeyPath<ItemFilters, [ItemFilters.Filter]>
|
||||
let selectorType: SelectorType
|
||||
}
|
||||
|
||||
let stack = NavigationStack(initial: \FilterCoordinator.start)
|
||||
@ -36,12 +34,7 @@ final class FilterCoordinator: NavigationCoordinatable {
|
||||
#if os(tvOS)
|
||||
Text(verbatim: .emptyDash)
|
||||
#else
|
||||
FilterView(
|
||||
title: parameters.title,
|
||||
viewModel: parameters.viewModel,
|
||||
filter: parameters.filter,
|
||||
selectorType: parameters.selectorType
|
||||
)
|
||||
FilterView(viewModel: parameters.viewModel, type: parameters.type)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -24,15 +24,11 @@ final class HomeCoordinator: NavigationCoordinatable {
|
||||
@Route(.modal)
|
||||
var item = makeItem
|
||||
@Route(.modal)
|
||||
var basicLibrary = makeBasicLibrary
|
||||
@Route(.modal)
|
||||
var library = makeLibrary
|
||||
#else
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var basicLibrary = makeBasicLibrary
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
#endif
|
||||
|
||||
@ -45,29 +41,21 @@ final class HomeCoordinator: NavigationCoordinatable {
|
||||
NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||
}
|
||||
|
||||
func makeBasicLibrary(parameters: BasicLibraryCoordinator.Parameters) -> NavigationViewCoordinator<BasicLibraryCoordinator> {
|
||||
NavigationViewCoordinator(BasicLibraryCoordinator(parameters: parameters))
|
||||
}
|
||||
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator<LibraryCoordinator> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(parameters: parameters))
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||
NavigationViewCoordinator(LibraryCoordinator<BaseItemDto>(viewModel: viewModel))
|
||||
}
|
||||
#else
|
||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeBasicLibrary(parameters: BasicLibraryCoordinator.Parameters) -> BasicLibraryCoordinator {
|
||||
BasicLibraryCoordinator(parameters: parameters)
|
||||
}
|
||||
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator {
|
||||
LibraryCoordinator(parameters: parameters)
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
HomeView(viewModel: .init())
|
||||
HomeView()
|
||||
}
|
||||
}
|
||||
|
@ -20,8 +20,6 @@ final class ItemCoordinator: NavigationCoordinatable {
|
||||
@Route(.push)
|
||||
var item = makeItem
|
||||
@Route(.push)
|
||||
var basicLibrary = makeBasicLibrary
|
||||
@Route(.push)
|
||||
var library = makeLibrary
|
||||
@Route(.push)
|
||||
var castAndCrew = makeCastAndCrew
|
||||
@ -54,22 +52,19 @@ final class ItemCoordinator: NavigationCoordinatable {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeBasicLibrary(parameters: BasicLibraryCoordinator.Parameters) -> BasicLibraryCoordinator {
|
||||
BasicLibraryCoordinator(parameters: parameters)
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator {
|
||||
LibraryCoordinator(parameters: parameters)
|
||||
}
|
||||
|
||||
func makeCastAndCrew(people: [BaseItemPerson]) -> CastAndCrewLibraryCoordinator {
|
||||
CastAndCrewLibraryCoordinator(people: people)
|
||||
func makeCastAndCrew(people: [BaseItemPerson]) -> LibraryCoordinator<BaseItemPerson> {
|
||||
let viewModel = PagingLibraryViewModel(people, parent: BaseItemDto(name: L10n.castAndCrew))
|
||||
return LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeItemOverview(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator(BasicNavigationViewCoordinator {
|
||||
NavigationViewCoordinator {
|
||||
ItemOverviewView(item: item)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeMediaSourceInfo(source: MediaSourceInfo) -> NavigationViewCoordinator<MediaSourceInfoCoordinator> {
|
||||
|
@ -12,29 +12,7 @@ import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class LibraryCoordinator: NavigationCoordinatable {
|
||||
|
||||
struct Parameters {
|
||||
let parent: LibraryParent?
|
||||
let type: LibraryParentType
|
||||
let filters: ItemFilters
|
||||
|
||||
init(
|
||||
parent: LibraryParent,
|
||||
type: LibraryParentType,
|
||||
filters: ItemFilters
|
||||
) {
|
||||
self.parent = parent
|
||||
self.type = type
|
||||
self.filters = filters
|
||||
}
|
||||
|
||||
init(filters: ItemFilters) {
|
||||
self.parent = nil
|
||||
self.type = .library
|
||||
self.filters = filters
|
||||
}
|
||||
}
|
||||
final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \LibraryCoordinator.start)
|
||||
|
||||
@ -55,28 +33,15 @@ final class LibraryCoordinator: NavigationCoordinatable {
|
||||
var filter = makeFilter
|
||||
#endif
|
||||
|
||||
private let parameters: Parameters
|
||||
private let viewModel: PagingLibraryViewModel<Element>
|
||||
|
||||
init(parameters: Parameters) {
|
||||
self.parameters = parameters
|
||||
init(viewModel: PagingLibraryViewModel<Element>) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
if let parent = parameters.parent {
|
||||
if parameters.filters == .init(), let id = parent.id, let storedFilters = Defaults[.libraryFilterStore][id] {
|
||||
LibraryView(viewModel: LibraryViewModel(parent: parent, type: parameters.type, filters: storedFilters, saveFilters: true))
|
||||
} else {
|
||||
LibraryView(viewModel: LibraryViewModel(
|
||||
parent: parent,
|
||||
type: parameters.type,
|
||||
filters: parameters.filters,
|
||||
saveFilters: false
|
||||
))
|
||||
}
|
||||
} else {
|
||||
LibraryView(viewModel: LibraryViewModel(filters: parameters.filters, saveFilters: false))
|
||||
}
|
||||
PagingLibraryView(viewModel: viewModel)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
@ -84,16 +49,16 @@ final class LibraryCoordinator: NavigationCoordinatable {
|
||||
NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||
}
|
||||
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator<LibraryCoordinator> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(parameters: parameters))
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||
NavigationViewCoordinator(LibraryCoordinator<BaseItemDto>(viewModel: viewModel))
|
||||
}
|
||||
#else
|
||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator {
|
||||
LibraryCoordinator(parameters: parameters)
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator<BaseItemDto>(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
||||
|
@ -47,18 +47,18 @@ final class LiveTVProgramsCoordinator: NavigationCoordinatable {
|
||||
func makeStart() -> some View {
|
||||
let viewModel = LiveTVProgramsViewModel()
|
||||
|
||||
let channels = (1 ..< 20).map { _ in BaseItemDto.randomItem() }
|
||||
|
||||
for channel in channels {
|
||||
viewModel.channels[channel.id!] = channel
|
||||
}
|
||||
|
||||
viewModel.recommendedItems = channels.randomSample(count: 5)
|
||||
viewModel.seriesItems = channels.randomSample(count: 5)
|
||||
viewModel.movieItems = channels.randomSample(count: 5)
|
||||
viewModel.sportsItems = channels.randomSample(count: 5)
|
||||
viewModel.kidsItems = channels.randomSample(count: 5)
|
||||
viewModel.newsItems = channels.randomSample(count: 5)
|
||||
// let channels = (1 ..< 20).map { _ in BaseItemDto.randomItem() }
|
||||
//
|
||||
// for channel in channels {
|
||||
// viewModel.channels[channel.id!] = channel
|
||||
// }
|
||||
//
|
||||
// viewModel.recommendedItems = channels.randomSample(count: 5)
|
||||
// viewModel.seriesItems = channels.randomSample(count: 5)
|
||||
// viewModel.movieItems = channels.randomSample(count: 5)
|
||||
// viewModel.sportsItems = channels.randomSample(count: 5)
|
||||
// viewModel.kidsItems = channels.randomSample(count: 5)
|
||||
// viewModel.newsItems = channels.randomSample(count: 5)
|
||||
|
||||
return LiveTVProgramsView(viewModel: viewModel)
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||
|
||||
init() {
|
||||
|
||||
if Container.userSession.callAsFunction().authenticated {
|
||||
if Container.userSession().authenticated {
|
||||
stack = NavigationStack(initial: \MainCoordinator.mainTab)
|
||||
} else {
|
||||
stack = NavigationStack(initial: \MainCoordinator.serverList)
|
||||
|
@ -30,7 +30,7 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||
|
||||
init() {
|
||||
|
||||
if Container.userSession.callAsFunction().authenticated {
|
||||
if Container.userSession().authenticated {
|
||||
stack = NavigationStack(initial: \MainCoordinator.mainTab)
|
||||
} else {
|
||||
stack = NavigationStack(initial: \MainCoordinator.serverList)
|
||||
|
@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
@ -45,12 +46,9 @@ final class MainTabCoordinator: TabCoordinatable {
|
||||
}
|
||||
}
|
||||
|
||||
func makeTVShows() -> NavigationViewCoordinator<BasicLibraryCoordinator> {
|
||||
let parameters = BasicLibraryCoordinator.Parameters(
|
||||
title: nil,
|
||||
viewModel: ItemTypeLibraryViewModel(itemTypes: [.series], filters: .init())
|
||||
)
|
||||
return NavigationViewCoordinator(BasicLibraryCoordinator(parameters: parameters))
|
||||
func makeTVShows() -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||
let viewModel = ItemTypeLibraryViewModel(itemTypes: [.series])
|
||||
return NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -61,12 +59,9 @@ final class MainTabCoordinator: TabCoordinatable {
|
||||
}
|
||||
}
|
||||
|
||||
func makeMovies() -> NavigationViewCoordinator<BasicLibraryCoordinator> {
|
||||
let parameters = BasicLibraryCoordinator.Parameters(
|
||||
title: nil,
|
||||
viewModel: ItemTypeLibraryViewModel(itemTypes: [.movie], filters: .init())
|
||||
)
|
||||
return NavigationViewCoordinator(BasicLibraryCoordinator(parameters: parameters))
|
||||
func makeMovies() -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||
let viewModel = ItemTypeLibraryViewModel(itemTypes: [.movie])
|
||||
return NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
@ -29,13 +30,13 @@ final class MediaCoordinator: NavigationCoordinatable {
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator<LibraryCoordinator> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(parameters: parameters))
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
||||
}
|
||||
|
||||
#else
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator {
|
||||
LibraryCoordinator(parameters: parameters)
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeLiveTV() -> LiveTVCoordinator {
|
||||
@ -49,6 +50,6 @@ final class MediaCoordinator: NavigationCoordinatable {
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
MediaView(viewModel: .init())
|
||||
MediaView()
|
||||
}
|
||||
}
|
||||
|
@ -36,16 +36,16 @@ final class SearchCoordinator: NavigationCoordinatable {
|
||||
NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||
}
|
||||
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> NavigationViewCoordinator<LibraryCoordinator> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(parameters: parameters))
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
|
||||
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
|
||||
}
|
||||
#else
|
||||
func makeItem(item: BaseItemDto) -> ItemCoordinator {
|
||||
ItemCoordinator(item: item)
|
||||
}
|
||||
|
||||
func makeLibrary(parameters: LibraryCoordinator.Parameters) -> LibraryCoordinator {
|
||||
LibraryCoordinator(parameters: parameters)
|
||||
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> LibraryCoordinator<BaseItemDto> {
|
||||
LibraryCoordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
|
||||
@ -55,6 +55,6 @@ final class SearchCoordinator: NavigationCoordinatable {
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
SearchView(viewModel: .init())
|
||||
SearchView()
|
||||
}
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||
@Route(.push)
|
||||
var experimentalSettings = makeExperimentalSettings
|
||||
@Route(.push)
|
||||
var filterDrawerButtonSelector = makeFilterDrawerButtonSelector
|
||||
var itemFilterDrawerSelector = makeItemFilterDrawerSelector
|
||||
@Route(.push)
|
||||
var indicatorSettings = makeIndicatorSettings
|
||||
@Route(.push)
|
||||
@ -117,8 +117,8 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||
}
|
||||
#endif
|
||||
|
||||
func makeFilterDrawerButtonSelector(selectedButtonsBinding: Binding<[FilterDrawerButtonSelection]>) -> some View {
|
||||
FilterDrawerButtonSelectorView(selectedButtonsBinding: selectedButtonsBinding)
|
||||
func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View {
|
||||
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
|
||||
}
|
||||
|
||||
func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
|
||||
|
@ -9,6 +9,7 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import PreferencesView
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
@ -29,7 +30,12 @@ final class VideoPlayerCoordinator: NavigationCoordinatable {
|
||||
func makeStart() -> some View {
|
||||
#if os(iOS)
|
||||
|
||||
PreferenceUIHostingControllerView {
|
||||
// Some settings have to apply to the root PreferencesView and this
|
||||
// one - separately.
|
||||
// It is assumed that because Stinsen adds a lot of views that the
|
||||
// PreferencesView isn't in the right place in the VC chain so that
|
||||
// it can apply the settings, even SwiftUI settings.
|
||||
PreferencesView {
|
||||
Group {
|
||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||
VideoPlayer(manager: self.videoPlayerManager)
|
||||
@ -37,20 +43,20 @@ final class VideoPlayerCoordinator: NavigationCoordinatable {
|
||||
NativeVideoPlayer(manager: self.videoPlayerManager)
|
||||
}
|
||||
}
|
||||
.overrideViewPreference(.dark)
|
||||
.preferredColorScheme(.dark)
|
||||
.supportedOrientations(UIDevice.isPhone ? .landscape : .allButUpsideDown)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.hideSystemOverlays()
|
||||
.onAppear {
|
||||
AppDelegate.enterPlaybackOrientation()
|
||||
}
|
||||
.backport
|
||||
.persistentSystemOverlays(.hidden)
|
||||
.backport
|
||||
.defersSystemGestures(on: .all)
|
||||
|
||||
#else
|
||||
if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin {
|
||||
PreferenceUIHostingControllerView {
|
||||
PreferencesView {
|
||||
VideoPlayer(manager: self.videoPlayerManager)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
} else {
|
||||
NativeVideoPlayer(manager: self.videoPlayerManager)
|
||||
}
|
||||
|
@ -26,13 +26,8 @@ final class VideoPlayerSettingsCoordinator: NavigationCoordinatable {
|
||||
var actionButtonSelector = makeActionButtonSelector
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
#endif
|
||||
|
||||
func makeFontPicker(selection: Binding<String>) -> some View {
|
||||
FontPickerView(selection: selection)
|
||||
.navigationTitle(L10n.subtitleFont)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@ -40,18 +35,13 @@ final class VideoPlayerSettingsCoordinator: NavigationCoordinatable {
|
||||
@ViewBuilder
|
||||
func makeGestureSettings() -> some View {
|
||||
GestureSettingsView()
|
||||
.navigationTitle("Gestures")
|
||||
}
|
||||
|
||||
func makeActionButtonSelector(selectedButtonsBinding: Binding<[VideoPlayerActionButton]>) -> some View {
|
||||
ActionButtonSelectorView(selectedButtonsBinding: selectedButtonsBinding)
|
||||
ActionButtonSelectorView(selection: selectedButtonsBinding)
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
VideoPlayerSettingsView()
|
||||
|
@ -9,6 +9,7 @@
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
// TODO: remove
|
||||
struct ErrorMessage: Hashable, Identifiable {
|
||||
|
||||
let code: Int?
|
||||
|
@ -14,8 +14,8 @@ extension Collection {
|
||||
Array(self)
|
||||
}
|
||||
|
||||
func sorted<Value: Comparable>(using keyPath: KeyPath<Element, Value>) -> [Element] {
|
||||
sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] })
|
||||
var isNotEmpty: Bool {
|
||||
!isEmpty
|
||||
}
|
||||
|
||||
subscript(safe index: Index) -> Element? {
|
||||
|
33
Shared/Extensions/Edge.swift
Normal file
33
Shared/Extensions/Edge.swift
Normal file
@ -0,0 +1,33 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension Edge.Set {
|
||||
|
||||
var asUIRectEdge: UIRectEdge {
|
||||
switch self {
|
||||
case .top:
|
||||
.top
|
||||
case .leading:
|
||||
.left
|
||||
case .bottom:
|
||||
.bottom
|
||||
case .trailing:
|
||||
.right
|
||||
case .all:
|
||||
.all
|
||||
case .horizontal:
|
||||
[.left, .right]
|
||||
case .vertical:
|
||||
[.top, .bottom]
|
||||
default:
|
||||
.all
|
||||
}
|
||||
}
|
||||
}
|
@ -8,6 +8,45 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension EdgeInsets {
|
||||
|
||||
// TODO: tvOS
|
||||
/// The default padding for View's against contextual edges,
|
||||
/// typically the edges of the View's scene
|
||||
static let defaultEdgePadding: CGFloat = {
|
||||
#if os(tvOS)
|
||||
50
|
||||
#else
|
||||
if UIDevice.isPad {
|
||||
24
|
||||
} else {
|
||||
16
|
||||
}
|
||||
#endif
|
||||
}()
|
||||
|
||||
static let DefaultEdgeInsets: EdgeInsets = .init(defaultEdgePadding)
|
||||
|
||||
init(_ constant: CGFloat) {
|
||||
self.init(top: constant, leading: constant, bottom: constant, trailing: constant)
|
||||
}
|
||||
|
||||
init(vertical: CGFloat = 0, horizontal: CGFloat = 0) {
|
||||
self.init(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSDirectionalEdgeInsets {
|
||||
|
||||
init(constant: CGFloat) {
|
||||
self.init(top: constant, leading: constant, bottom: constant, trailing: constant)
|
||||
}
|
||||
|
||||
init(vertical: CGFloat = 0, horizontal: CGFloat = 0) {
|
||||
self.init(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIEdgeInsets {
|
||||
|
||||
var asEdgeInsets: EdgeInsets {
|
||||
|
@ -1,88 +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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// TODO: Look at name spacing
|
||||
// TODO: Consistent naming: ...Key
|
||||
|
||||
struct AudioOffset: EnvironmentKey {
|
||||
static let defaultValue: Binding<Int> = .constant(0)
|
||||
}
|
||||
|
||||
struct AspectFilled: EnvironmentKey {
|
||||
static let defaultValue: Binding<Bool> = .constant(false)
|
||||
}
|
||||
|
||||
struct CurrentOverlayType: EnvironmentKey {
|
||||
static let defaultValue: Binding<VideoPlayer.OverlayType> = .constant(.main)
|
||||
}
|
||||
|
||||
struct IsScrubbing: EnvironmentKey {
|
||||
static let defaultValue: Binding<Bool> = .constant(false)
|
||||
}
|
||||
|
||||
struct PlaybackSpeedKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Float> = .constant(1)
|
||||
}
|
||||
|
||||
struct SafeAreaInsetsKey: EnvironmentKey {
|
||||
static var defaultValue: EdgeInsets {
|
||||
UIApplication.shared.keyWindow?.safeAreaInsets.asEdgeInsets ?? .zero
|
||||
}
|
||||
}
|
||||
|
||||
struct SubtitleOffset: EnvironmentKey {
|
||||
static let defaultValue: Binding<Int> = .constant(0)
|
||||
}
|
||||
|
||||
struct IsPresentingOverlayKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Bool> = .constant(false)
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
var isPresentingOverlay: Binding<Bool> {
|
||||
get { self[IsPresentingOverlayKey.self] }
|
||||
set { self[IsPresentingOverlayKey.self] = newValue }
|
||||
}
|
||||
|
||||
var audioOffset: Binding<Int> {
|
||||
get { self[AudioOffset.self] }
|
||||
set { self[AudioOffset.self] = newValue }
|
||||
}
|
||||
|
||||
var aspectFilled: Binding<Bool> {
|
||||
get { self[AspectFilled.self] }
|
||||
set { self[AspectFilled.self] = newValue }
|
||||
}
|
||||
|
||||
var currentOverlayType: Binding<VideoPlayer.OverlayType> {
|
||||
get { self[CurrentOverlayType.self] }
|
||||
set { self[CurrentOverlayType.self] = newValue }
|
||||
}
|
||||
|
||||
var isScrubbing: Binding<Bool> {
|
||||
get { self[IsScrubbing.self] }
|
||||
set { self[IsScrubbing.self] = newValue }
|
||||
}
|
||||
|
||||
var playbackSpeed: Binding<Float> {
|
||||
get { self[PlaybackSpeedKey.self] }
|
||||
set { self[PlaybackSpeedKey.self] = newValue }
|
||||
}
|
||||
|
||||
var safeAreaInsets: EdgeInsets {
|
||||
self[SafeAreaInsetsKey.self]
|
||||
}
|
||||
|
||||
var subtitleOffset: Binding<Int> {
|
||||
get { self[SubtitleOffset.self] }
|
||||
set { self[SubtitleOffset.self] = newValue }
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
struct AccentColorKey: EnvironmentKey {
|
||||
static let defaultValue: Color = Defaults[.accentColor]
|
||||
}
|
||||
|
||||
struct AudioOffsetKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Int> = .constant(0)
|
||||
}
|
||||
|
||||
struct AspectFilledKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Bool> = .constant(false)
|
||||
}
|
||||
|
||||
struct CurrentOverlayTypeKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<VideoPlayer.OverlayType> = .constant(.main)
|
||||
}
|
||||
|
||||
struct IsScrubbingKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Bool> = .constant(false)
|
||||
}
|
||||
|
||||
struct PlaybackSpeedKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Float> = .constant(1)
|
||||
}
|
||||
|
||||
// TODO: does this actually do anything useful?
|
||||
// should instead use view safe area?
|
||||
struct SafeAreaInsetsKey: EnvironmentKey {
|
||||
static var defaultValue: EdgeInsets {
|
||||
UIApplication.shared.keyWindow?.safeAreaInsets.asEdgeInsets ?? .zero
|
||||
}
|
||||
}
|
||||
|
||||
struct ShowsLibraryFiltersKey: EnvironmentKey {
|
||||
static let defaultValue: Bool = true
|
||||
}
|
||||
|
||||
struct SubtitleOffsetKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Int> = .constant(0)
|
||||
}
|
||||
|
||||
struct IsPresentingOverlayKey: EnvironmentKey {
|
||||
static let defaultValue: Binding<Bool> = .constant(false)
|
||||
}
|
||||
}
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
|
||||
var accentColor: Color {
|
||||
get { self[AccentColorKey.self] }
|
||||
set { self[AccentColorKey.self] = newValue }
|
||||
}
|
||||
|
||||
var audioOffset: Binding<Int> {
|
||||
get { self[AudioOffsetKey.self] }
|
||||
set { self[AudioOffsetKey.self] = newValue }
|
||||
}
|
||||
|
||||
var aspectFilled: Binding<Bool> {
|
||||
get { self[AspectFilledKey.self] }
|
||||
set { self[AspectFilledKey.self] = newValue }
|
||||
}
|
||||
|
||||
var currentOverlayType: Binding<VideoPlayer.OverlayType> {
|
||||
get { self[CurrentOverlayTypeKey.self] }
|
||||
set { self[CurrentOverlayTypeKey.self] = newValue }
|
||||
}
|
||||
|
||||
var isPresentingOverlay: Binding<Bool> {
|
||||
get { self[IsPresentingOverlayKey.self] }
|
||||
set { self[IsPresentingOverlayKey.self] = newValue }
|
||||
}
|
||||
|
||||
var isScrubbing: Binding<Bool> {
|
||||
get { self[IsScrubbingKey.self] }
|
||||
set { self[IsScrubbingKey.self] = newValue }
|
||||
}
|
||||
|
||||
var playbackSpeed: Binding<Float> {
|
||||
get { self[PlaybackSpeedKey.self] }
|
||||
set { self[PlaybackSpeedKey.self] = newValue }
|
||||
}
|
||||
|
||||
var safeAreaInsets: EdgeInsets {
|
||||
self[SafeAreaInsetsKey.self]
|
||||
}
|
||||
|
||||
// TODO: remove and make a parameter instead, isn't necessarily an
|
||||
// environment value
|
||||
var showsLibraryFilters: Bool {
|
||||
get { self[ShowsLibraryFiltersKey.self] }
|
||||
set { self[ShowsLibraryFiltersKey.self] = newValue }
|
||||
}
|
||||
|
||||
var subtitleOffset: Binding<Int> {
|
||||
get { self[SubtitleOffsetKey.self] }
|
||||
set { self[SubtitleOffsetKey.self] = newValue }
|
||||
}
|
||||
}
|
@ -10,14 +10,6 @@ import Foundation
|
||||
|
||||
extension Equatable {
|
||||
|
||||
func random(in range: Range<Int>) -> [Self] {
|
||||
Array(repeating: self, count: Int.random(in: range))
|
||||
}
|
||||
|
||||
func repeating(count: Int) -> [Self] {
|
||||
Array(repeating: self, count: count)
|
||||
}
|
||||
|
||||
func mutating<Value>(_ keyPath: WritableKeyPath<Self, Value>, with newValue: Value) -> Self {
|
||||
var copy = self
|
||||
copy[keyPath: keyPath] = newValue
|
||||
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension ForEach where Content: View {
|
||||
|
||||
@ViewBuilder
|
||||
static func `let`(
|
||||
_ data: Data?,
|
||||
id: KeyPath<Data.Element, ID>,
|
||||
@ViewBuilder content: @escaping (Data.Element) -> Content
|
||||
) -> some View {
|
||||
if let data {
|
||||
ForEach(data, id: id, content: content)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
static func `let`(_ data: Data?, @ViewBuilder content: @escaping (Data.Element) -> Content) -> some View where ID == Data.Element.ID,
|
||||
Data.Element: Identifiable {
|
||||
if let data {
|
||||
ForEach(data, content: content)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
@ -17,4 +17,12 @@ extension HorizontalAlignment {
|
||||
}
|
||||
|
||||
static let VideoPlayerTitleAlignmentGuide = HorizontalAlignment(VideoPlayerTitleAlignment.self)
|
||||
|
||||
struct LibraryRowContentAlignment: AlignmentID {
|
||||
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||
context[HorizontalAlignment.leading]
|
||||
}
|
||||
}
|
||||
|
||||
static let LeadingLibraryRowContentAlignmentGuide = HorizontalAlignment(LibraryRowContentAlignment.self)
|
||||
}
|
||||
|
@ -16,9 +16,9 @@ extension FixedWidthInteger {
|
||||
let seconds = self % 3600 % 60
|
||||
|
||||
let hourText = hours > 0 ? String(hours).appending(":") : ""
|
||||
let minutesText = hours > 0 ? String(minutes).leftPad(toWidth: 2, withString: "0").appending(":") : String(minutes)
|
||||
let minutesText = hours > 0 ? String(minutes).leftPad(maxWidth: 2, with: "0").appending(":") : String(minutes)
|
||||
.appending(":")
|
||||
let secondsText = String(seconds).leftPad(toWidth: 2, withString: "0")
|
||||
let secondsText = String(seconds).leftPad(maxWidth: 2, with: "0")
|
||||
|
||||
return hourText
|
||||
.appending(minutesText)
|
||||
@ -28,8 +28,8 @@ extension FixedWidthInteger {
|
||||
|
||||
extension Int {
|
||||
|
||||
/// Format if the current value represents milliseconds
|
||||
var millisecondFormat: String {
|
||||
/// Label if the current value represents milliseconds
|
||||
var millisecondLabel: String {
|
||||
let isNegative = self < 0
|
||||
let value = abs(self)
|
||||
let seconds = "\(value / 1000)"
|
||||
@ -42,8 +42,8 @@ extension Int {
|
||||
.prepending("-", if: isNegative)
|
||||
}
|
||||
|
||||
// Format if the current value represents seconds
|
||||
var secondFormat: String {
|
||||
/// Label if the current value represents seconds
|
||||
var secondLabel: String {
|
||||
let isNegative = self < 0
|
||||
let value = abs(self)
|
||||
let seconds = "\(value)"
|
||||
@ -52,4 +52,12 @@ extension Int {
|
||||
.appending("s")
|
||||
.prepending("-", if: isNegative)
|
||||
}
|
||||
|
||||
init?(_ source: CGFloat?) {
|
||||
if let source = source {
|
||||
self.init(source)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -81,14 +81,12 @@ extension BaseItemDto {
|
||||
maxHeight: Int?,
|
||||
itemID: String
|
||||
) -> URL? {
|
||||
|
||||
// TODO: See if the scaling is actually right so that it isn't so big
|
||||
let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!)
|
||||
let scaleHeight = maxHeight == nil ? nil : UIScreen.main.scale(maxHeight!)
|
||||
|
||||
guard let tag = getImageTag(for: type) else { return nil }
|
||||
|
||||
let client = Container.userSession.callAsFunction().client
|
||||
let client = Container.userSession().client
|
||||
let parameters = Paths.GetItemImageParameters(
|
||||
maxWidth: scaleWidth,
|
||||
maxHeight: scaleHeight,
|
||||
@ -115,19 +113,9 @@ extension BaseItemDto {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func _imageSource(_ type: ImageType, maxWidth: Int?, maxHeight: Int?) -> ImageSource {
|
||||
private func _imageSource(_ type: ImageType, maxWidth: Int?, maxHeight: Int?) -> ImageSource {
|
||||
let url = _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: id ?? "")
|
||||
let blurHash = blurHash(type)
|
||||
return ImageSource(url: url, blurHash: blurHash)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension Int {
|
||||
init?(_ source: CGFloat?) {
|
||||
if let source = source {
|
||||
self.init(source)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
@ -27,7 +27,7 @@ extension BaseItemDto: Poster {
|
||||
var subtitle: String? {
|
||||
switch type {
|
||||
case .episode:
|
||||
return seasonEpisodeLocator
|
||||
return seasonEpisodeLabel
|
||||
case .video:
|
||||
return extraType?.displayTitle
|
||||
default:
|
||||
@ -44,10 +44,24 @@ extension BaseItemDto: Poster {
|
||||
}
|
||||
}
|
||||
|
||||
var typeSystemName: String? {
|
||||
switch type {
|
||||
case .episode, .movie, .series:
|
||||
"film"
|
||||
case .folder:
|
||||
"folder.fill"
|
||||
case .person:
|
||||
"person.fill"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource {
|
||||
switch type {
|
||||
case .episode:
|
||||
return seriesImageSource(.primary, maxWidth: maxWidth)
|
||||
case .folder:
|
||||
return ImageSource()
|
||||
default:
|
||||
return imageSource(.primary, maxWidth: maxWidth)
|
||||
}
|
||||
@ -65,6 +79,8 @@ extension BaseItemDto: Poster {
|
||||
imageSource(.primary, maxWidth: maxWidth),
|
||||
]
|
||||
}
|
||||
case .folder:
|
||||
return [imageSource(.primary, maxWidth: maxWidth)]
|
||||
case .video:
|
||||
return [imageSource(.primary, maxWidth: maxWidth)]
|
||||
default:
|
@ -6,12 +6,10 @@
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Defaults
|
||||
import Factory
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension BaseItemDto {
|
||||
|
||||
@ -20,9 +18,9 @@ extension BaseItemDto {
|
||||
let currentVideoPlayerType = Defaults[.VideoPlayer.videoPlayerType]
|
||||
// TODO: fix bitrate settings
|
||||
let tempOverkillBitrate = 360_000_000
|
||||
let profile = DeviceProfileBuilder.buildProfile(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate)
|
||||
let profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate)
|
||||
|
||||
let userSession = Container.userSession.callAsFunction()
|
||||
let userSession = Container.userSession()
|
||||
|
||||
let playbackInfo = PlaybackInfoDto(deviceProfile: profile)
|
||||
let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters(
|
@ -19,7 +19,11 @@ extension BaseItemDto: Displayable {
|
||||
}
|
||||
}
|
||||
|
||||
extension BaseItemDto: LibraryParent {}
|
||||
extension BaseItemDto: LibraryParent {
|
||||
var libraryType: BaseItemKind? {
|
||||
type
|
||||
}
|
||||
}
|
||||
|
||||
extension BaseItemDto {
|
||||
|
||||
@ -28,16 +32,19 @@ extension BaseItemDto {
|
||||
return L10n.episodeNumber(episodeNo)
|
||||
}
|
||||
|
||||
var itemGenres: [ItemGenre]? {
|
||||
guard let genres else { return nil }
|
||||
return genres.map(ItemGenre.init)
|
||||
}
|
||||
|
||||
var runTimeSeconds: Int {
|
||||
let playbackPositionTicks = runTimeTicks ?? 0
|
||||
return Int(playbackPositionTicks / 10_000_000)
|
||||
}
|
||||
|
||||
var seasonEpisodeLocator: String? {
|
||||
if let seasonNo = parentIndexNumber, let episodeNo = indexNumber {
|
||||
return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo))
|
||||
}
|
||||
return nil
|
||||
var seasonEpisodeLabel: String? {
|
||||
guard let seasonNo = parentIndexNumber, let episodeNo = indexNumber else { return nil }
|
||||
return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo))
|
||||
}
|
||||
|
||||
var startTimeSeconds: Int {
|
||||
@ -47,8 +54,7 @@ extension BaseItemDto {
|
||||
|
||||
// MARK: Calculations
|
||||
|
||||
// TODO: make computed var or function that takes allowed units
|
||||
func getItemRuntime() -> String? {
|
||||
var runTimeLabel: String? {
|
||||
let timeHMSFormatter: DateComponentsFormatter = {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
@ -92,8 +98,8 @@ extension BaseItemDto {
|
||||
}
|
||||
|
||||
func getLiveProgressPercentage() -> Double {
|
||||
if let startDate = self.startDate,
|
||||
let endDate = self.endDate
|
||||
if let startDate,
|
||||
let endDate
|
||||
{
|
||||
let start = startDate.timeIntervalSinceReferenceDate
|
||||
let end = endDate.timeIntervalSinceReferenceDate
|
||||
@ -132,11 +138,11 @@ extension BaseItemDto {
|
||||
}
|
||||
|
||||
var airDateLabel: String? {
|
||||
guard let premiereDateFormatted = premiereDateFormatted else { return nil }
|
||||
guard let premiereDateFormatted = premiereDateLabel else { return nil }
|
||||
return L10n.airWithDate(premiereDateFormatted)
|
||||
}
|
||||
|
||||
var premiereDateFormatted: String? {
|
||||
var premiereDateLabel: String? {
|
||||
guard let premiereDate = premiereDate else { return nil }
|
||||
|
||||
let dateFormatter = DateFormatter()
|
||||
@ -153,7 +159,7 @@ extension BaseItemDto {
|
||||
|
||||
var hasExternalLinks: Bool {
|
||||
guard let externalURLs else { return false }
|
||||
return !externalURLs.isEmpty
|
||||
return externalURLs.isNotEmpty
|
||||
}
|
||||
|
||||
var hasRatings: Bool {
|
||||
@ -188,8 +194,7 @@ extension BaseItemDto {
|
||||
)
|
||||
|
||||
let imageURL = Container
|
||||
.userSession
|
||||
.callAsFunction()
|
||||
.userSession()
|
||||
.client
|
||||
.fullURL(with: request)
|
||||
|
||||
@ -228,26 +233,30 @@ extension BaseItemDto {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Don't use spoof objects as a placeholder or no results
|
||||
// TODO: move as extension on `BaseItemKind`
|
||||
// TODO: remove when `collectionType` becomes an enum
|
||||
func includedItemTypesForCollectionType() -> [BaseItemKind]? {
|
||||
|
||||
static var placeHolder: BaseItemDto {
|
||||
.init(
|
||||
id: "1",
|
||||
name: "Placeholder",
|
||||
overview: String(repeating: "a", count: 100)
|
||||
// indexNumber: 20
|
||||
)
|
||||
guard let collectionType else { return nil }
|
||||
|
||||
var itemTypes: [BaseItemKind]?
|
||||
|
||||
switch collectionType {
|
||||
case "movies":
|
||||
itemTypes = [.movie]
|
||||
case "tvshows":
|
||||
itemTypes = [.series]
|
||||
case "mixed":
|
||||
itemTypes = [.movie, .series]
|
||||
default:
|
||||
itemTypes = nil
|
||||
}
|
||||
|
||||
return itemTypes
|
||||
}
|
||||
|
||||
static func randomItem() -> BaseItemDto {
|
||||
.init(
|
||||
id: UUID().uuidString,
|
||||
name: "Lorem Ipsum",
|
||||
overview: "Lorem ipsum dolor sit amet"
|
||||
)
|
||||
}
|
||||
|
||||
static var noResults: BaseItemDto {
|
||||
.init(name: L10n.noResults)
|
||||
/// Returns `originalTitle` if it is not the same as `displayTitle`
|
||||
var alternateTitle: String? {
|
||||
originalTitle != displayTitle ? originalTitle : nil
|
||||
}
|
||||
}
|
@ -14,16 +14,20 @@ import UIKit
|
||||
extension BaseItemPerson: Poster {
|
||||
|
||||
var subtitle: String? {
|
||||
self.firstRole
|
||||
firstRole
|
||||
}
|
||||
|
||||
var showTitle: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
var typeSystemName: String? {
|
||||
"person.fill"
|
||||
}
|
||||
|
||||
func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource {
|
||||
let scaleWidth = UIScreen.main.scale(maxWidth)
|
||||
let client = Container.userSession.callAsFunction().client
|
||||
let client = Container.userSession().client
|
||||
let imageRequestParameters = Paths.GetItemImageParameters(
|
||||
maxWidth: scaleWidth,
|
||||
tag: primaryImageTag
|
@ -16,12 +16,14 @@ extension BaseItemPerson: Displayable {
|
||||
}
|
||||
}
|
||||
|
||||
extension BaseItemPerson: LibraryParent {}
|
||||
extension BaseItemPerson: LibraryParent {
|
||||
var libraryType: BaseItemKind? {
|
||||
.person
|
||||
}
|
||||
}
|
||||
|
||||
extension BaseItemPerson {
|
||||
|
||||
// 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
|
@ -31,7 +31,11 @@ extension ChapterInfo {
|
||||
|
||||
extension ChapterInfo {
|
||||
|
||||
struct FullInfo: Poster, Hashable {
|
||||
struct FullInfo: Poster {
|
||||
|
||||
var id: Int {
|
||||
chapterInfo.hashValue
|
||||
}
|
||||
|
||||
let chapterInfo: ChapterInfo
|
||||
let imageSource: ImageSource
|
||||
@ -41,6 +45,7 @@ extension ChapterInfo {
|
||||
chapterInfo.displayTitle
|
||||
}
|
||||
|
||||
let typeSystemName: String? = "film"
|
||||
var subtitle: String?
|
||||
var showTitle: Bool = true
|
||||
|
||||
|
@ -0,0 +1,90 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
|
||||
extension DeviceProfile {
|
||||
|
||||
static func nativeProfile() -> DeviceProfile {
|
||||
|
||||
var profile: DeviceProfile = .init()
|
||||
|
||||
// Build direct play profiles
|
||||
profile.directPlayProfiles = [
|
||||
// Apple limitation: no mp3 in mp4; avi only supports mjpeg with pcm
|
||||
// Right now, mp4 restrictions can't be enforced because mp4, m4v, mov, 3gp,3g2 treated the same
|
||||
DirectPlayProfile(
|
||||
audioCodec: "flac,alac,aac,eac3,ac3,opus",
|
||||
container: "mp4",
|
||||
type: .video,
|
||||
videoCodec: "hevc,h264,mpeg4"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "alac,aac,ac3",
|
||||
container: "m4v",
|
||||
type: .video,
|
||||
videoCodec: "h264,mpeg4"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "alac,aac,eac3,ac3,mp3,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le",
|
||||
container: "mov",
|
||||
type: .video,
|
||||
videoCodec: "hevc,h264,mpeg4,mjpeg"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "aac,eac3,ac3,mp3",
|
||||
container: "mpegts",
|
||||
type: .video,
|
||||
videoCodec: "h264"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "aac,amr_nb",
|
||||
container: "3gp,3g2",
|
||||
type: .video,
|
||||
videoCodec: "h264,mpeg4"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "pcm_s16le,pcm_mulaw",
|
||||
container: "avi",
|
||||
type: .video,
|
||||
videoCodec: "mjpeg"
|
||||
),
|
||||
]
|
||||
|
||||
// Build transcoding profiles
|
||||
profile.transcodingProfiles = [
|
||||
TranscodingProfile(
|
||||
audioCodec: "flac,alac,aac,eac3,ac3,opus",
|
||||
isBreakOnNonKeyFrames: true,
|
||||
container: "mp4",
|
||||
context: .streaming,
|
||||
maxAudioChannels: "8",
|
||||
minSegments: 2,
|
||||
protocol: "hls",
|
||||
type: .video,
|
||||
videoCodec: "hevc,h264,mpeg4"
|
||||
),
|
||||
]
|
||||
|
||||
// Create subtitle profiles
|
||||
profile.subtitleProfiles = [
|
||||
// FFmpeg can only convert bitmap to bitmap and text to text; burn in bitmap subs
|
||||
SubtitleProfile(format: "pgssub", method: .encode),
|
||||
SubtitleProfile(format: "dvdsub", method: .encode),
|
||||
SubtitleProfile(format: "dvbsub", method: .encode),
|
||||
SubtitleProfile(format: "xsub", method: .encode),
|
||||
// According to Apple HLS authoring specs, WebVTT must be in a text file delivered via HLS
|
||||
SubtitleProfile(format: "vtt", method: .hls), // webvtt
|
||||
// Apple HLS authoring spec has closed captions in video segments and TTML in fmp4
|
||||
SubtitleProfile(format: "ttml", method: .embed),
|
||||
SubtitleProfile(format: "cc_dec", method: .embed),
|
||||
]
|
||||
|
||||
return profile
|
||||
}
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
|
||||
extension DeviceProfile {
|
||||
|
||||
// For now, assume native and VLCKit support same codec conditions
|
||||
static func sharedCodecProfiles() -> [CodecProfile] {
|
||||
|
||||
var codecProfiles: [CodecProfile] = []
|
||||
|
||||
let h264CodecConditions: [ProfileCondition] = [
|
||||
ProfileCondition(
|
||||
condition: .notEquals,
|
||||
isRequired: false,
|
||||
property: .isAnamorphic,
|
||||
value: "true"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .equalsAny,
|
||||
isRequired: false,
|
||||
property: .videoProfile,
|
||||
value: "high|main|baseline|constrained baseline"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .lessThanEqual,
|
||||
isRequired: false,
|
||||
property: .videoLevel,
|
||||
value: "80"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .notEquals,
|
||||
isRequired: false,
|
||||
property: .isInterlaced,
|
||||
value: "true"
|
||||
),
|
||||
]
|
||||
|
||||
codecProfiles.append(CodecProfile(applyConditions: h264CodecConditions, codec: "h264", type: .video))
|
||||
|
||||
let hevcCodecConditions: [ProfileCondition] = [
|
||||
ProfileCondition(
|
||||
condition: .notEquals,
|
||||
isRequired: false,
|
||||
property: .isAnamorphic,
|
||||
value: "true"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .equalsAny,
|
||||
isRequired: false,
|
||||
property: .videoProfile,
|
||||
value: "high|main|main 10"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .lessThanEqual,
|
||||
isRequired: false,
|
||||
property: .videoLevel,
|
||||
value: "175"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .notEquals,
|
||||
isRequired: false,
|
||||
property: .isInterlaced,
|
||||
value: "true"
|
||||
),
|
||||
]
|
||||
|
||||
codecProfiles.append(CodecProfile(applyConditions: hevcCodecConditions, codec: "hevc", type: .video))
|
||||
|
||||
return codecProfiles
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
|
||||
extension DeviceProfile {
|
||||
|
||||
static func swiftfinProfile() -> DeviceProfile {
|
||||
|
||||
var profile: DeviceProfile = .init()
|
||||
|
||||
// Build direct play profiles
|
||||
profile.directPlayProfiles = [
|
||||
// Just make one profile because if VLCKit can't decode it in a certain container, ffmpeg probably can't decode it for
|
||||
// transcode either
|
||||
DirectPlayProfile(
|
||||
// No need to list containers or videocodecs since if jellyfin server can detect it/ffmpeg can decode it, so can
|
||||
// VLCKit
|
||||
// However, list audiocodecs because ffmpeg can decode TrueHD/mlp but VLCKit cannot
|
||||
audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le,pcm_u8,pcm_alaw,pcm_mulaw,pcm_bluray,pcm_dvd,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb",
|
||||
type: .video
|
||||
),
|
||||
]
|
||||
|
||||
// Build transcoding profiles
|
||||
// The only cases where transcoding should occur:
|
||||
// 1) TrueHD/mlp audio
|
||||
// 2) When server forces transcode for bitrate reasons
|
||||
profile.transcodingProfiles = [TranscodingProfile(
|
||||
audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1",
|
||||
// no PCM,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb in mp4
|
||||
isBreakOnNonKeyFrames: true,
|
||||
container: "mp4",
|
||||
context: .streaming,
|
||||
maxAudioChannels: "8",
|
||||
minSegments: 2,
|
||||
protocol: "hls",
|
||||
type: .video,
|
||||
videoCodec: "hevc,h264,av1,vp9,vc1,mpeg4,h263,mpeg2video,mpeg1video,mjpeg" // vp8,msmpeg4v3,msmpeg4v2,msmpeg4v1,theora,ffv1,flv1,wmv3,wmv2,wmv1
|
||||
// not supported in mp4
|
||||
)]
|
||||
|
||||
// Create subtitle profiles
|
||||
profile.subtitleProfiles = [
|
||||
SubtitleProfile(format: "pgssub", method: .embed), // *pgs* normalized to pgssub; includes sup
|
||||
SubtitleProfile(format: "dvdsub", method: .embed),
|
||||
// *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case?
|
||||
SubtitleProfile(format: "subrip", method: .embed), // srt
|
||||
SubtitleProfile(format: "ass", method: .embed),
|
||||
SubtitleProfile(format: "ssa", method: .embed),
|
||||
SubtitleProfile(format: "vtt", method: .embed), // webvtt
|
||||
SubtitleProfile(format: "mov_text", method: .embed), // MPEG-4 Timed Text
|
||||
SubtitleProfile(format: "ttml", method: .embed),
|
||||
SubtitleProfile(format: "text", method: .embed), // txt
|
||||
SubtitleProfile(format: "dvbsub", method: .embed),
|
||||
// dvb_subtitle normalized to dvbsub; burned in during transcode regardless?
|
||||
SubtitleProfile(format: "libzvbi_teletextdec", method: .embed), // dvb_teletext
|
||||
SubtitleProfile(format: "xsub", method: .embed),
|
||||
SubtitleProfile(format: "vplayer", method: .embed),
|
||||
SubtitleProfile(format: "subviewer", method: .embed),
|
||||
SubtitleProfile(format: "subviewer1", method: .embed),
|
||||
SubtitleProfile(format: "sami", method: .embed), // SMI
|
||||
SubtitleProfile(format: "realtext", method: .embed),
|
||||
SubtitleProfile(format: "pjs", method: .embed), // Phoenix Subtitle
|
||||
SubtitleProfile(format: "mpl2", method: .embed),
|
||||
SubtitleProfile(format: "jacosub", method: .embed),
|
||||
SubtitleProfile(format: "cc_dec", method: .embed), // eia_608
|
||||
// Can be passed as external files; ones that jellyfin can encode to must come first
|
||||
SubtitleProfile(format: "subrip", method: .external), // srt
|
||||
SubtitleProfile(format: "ttml", method: .external),
|
||||
SubtitleProfile(format: "vtt", method: .external), // webvtt
|
||||
SubtitleProfile(format: "ass", method: .external),
|
||||
SubtitleProfile(format: "ssa", method: .external),
|
||||
SubtitleProfile(format: "pgssub", method: .external),
|
||||
SubtitleProfile(format: "text", method: .external), // txt
|
||||
SubtitleProfile(format: "dvbsub", method: .external), // dvb_subtitle normalized to dvbsub
|
||||
SubtitleProfile(format: "libzvbi_teletextdec", method: .external), // dvb_teletext
|
||||
SubtitleProfile(format: "dvdsub", method: .external),
|
||||
// *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case?
|
||||
SubtitleProfile(format: "xsub", method: .external),
|
||||
SubtitleProfile(format: "vplayer", method: .external),
|
||||
SubtitleProfile(format: "subviewer", method: .external),
|
||||
SubtitleProfile(format: "subviewer1", method: .external),
|
||||
SubtitleProfile(format: "sami", method: .external), // SMI
|
||||
SubtitleProfile(format: "realtext", method: .external),
|
||||
SubtitleProfile(format: "pjs", method: .external), // Phoenix Subtitle
|
||||
SubtitleProfile(format: "mpl2", method: .external),
|
||||
SubtitleProfile(format: "jacosub", method: .external),
|
||||
]
|
||||
|
||||
return profile
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
|
||||
extension DeviceProfile {
|
||||
|
||||
static func build(for videoPlayer: VideoPlayerType, maxBitrate: Int? = nil) -> DeviceProfile {
|
||||
|
||||
var deviceProfile: DeviceProfile
|
||||
|
||||
switch videoPlayer {
|
||||
case .native:
|
||||
deviceProfile = nativeProfile()
|
||||
case .swiftfin:
|
||||
deviceProfile = swiftfinProfile()
|
||||
}
|
||||
|
||||
let codecProfiles: [CodecProfile] = sharedCodecProfiles()
|
||||
let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", mimeType: "video/mp4", type: .video)]
|
||||
|
||||
deviceProfile.codecProfiles = codecProfiles
|
||||
deviceProfile.responseProfiles = responseProfiles
|
||||
|
||||
if let maxBitrate {
|
||||
deviceProfile.maxStaticBitrate = maxBitrate
|
||||
deviceProfile.maxStreamingBitrate = maxBitrate
|
||||
deviceProfile.musicStreamingTranscodingBitrate = maxBitrate
|
||||
}
|
||||
|
||||
return deviceProfile
|
||||
}
|
||||
}
|
@ -11,11 +11,20 @@ import JellyfinAPI
|
||||
|
||||
extension ItemFields {
|
||||
|
||||
static let minimumCases: [ItemFields] = [
|
||||
.chapters,
|
||||
/// The minimum cases to use when retrieving an item or items
|
||||
/// for basic presentation. Depending on the context, using
|
||||
/// more fields and including user data may also be necessary.
|
||||
static let MinimumFields: [ItemFields] = [
|
||||
.mediaSources,
|
||||
.overview,
|
||||
.parentID,
|
||||
.taglines,
|
||||
]
|
||||
}
|
||||
|
||||
extension Array where Element == ItemFields {
|
||||
|
||||
static var MinimumFields: Self {
|
||||
ItemFields.MinimumFields
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,23 @@
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
extension ItemFilter: Displayable {
|
||||
/// Aliased so the name `ItemFilter` can be repurposed.
|
||||
///
|
||||
/// - Important: Make sure to use the correct `filters` parameter for item calls!
|
||||
typealias ItemTrait = JellyfinAPI.ItemFilter
|
||||
|
||||
extension ItemTrait: ItemFilter {
|
||||
|
||||
var value: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
init(from anyFilter: AnyItemFilter) {
|
||||
self.init(rawValue: anyFilter.value)!
|
||||
}
|
||||
}
|
||||
|
||||
extension ItemTrait: Displayable {
|
||||
// TODO: Localize
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
@ -27,13 +43,14 @@ extension ItemFilter: Displayable {
|
||||
}
|
||||
}
|
||||
|
||||
extension ItemFilter {
|
||||
extension ItemTrait: SupportedCaseIterable {
|
||||
|
||||
static var supportedCases: [ItemFilter] {
|
||||
[.isUnplayed, .isPlayed, .isFavorite, .likes]
|
||||
}
|
||||
|
||||
var filter: ItemFilters.Filter {
|
||||
.init(displayTitle: displayTitle, id: rawValue, filterName: rawValue)
|
||||
static var supportedCases: [ItemTrait] {
|
||||
[
|
||||
.isUnplayed,
|
||||
.isPlayed,
|
||||
.isFavorite,
|
||||
.likes,
|
||||
]
|
||||
}
|
||||
}
|
@ -8,7 +8,13 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct JellyfinAPIError: Error {
|
||||
// TODO: rename to `ErrorMessage` and remove other implementation
|
||||
|
||||
/// A basic error that holds a message, useful for debugging.
|
||||
///
|
||||
/// - Important: Only really use for debugging. For practical errors,
|
||||
/// statically define errors for each domain/context.
|
||||
struct JellyfinAPIError: LocalizedError, Equatable {
|
||||
|
||||
private let message: String
|
||||
|
||||
@ -16,7 +22,7 @@ struct JellyfinAPIError: Error {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
var localizedDescription: String {
|
||||
var errorDescription: String? {
|
||||
message
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ extension MediaSourceInfo {
|
||||
|
||||
func videoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel {
|
||||
|
||||
let userSession = Container.userSession.callAsFunction()
|
||||
let userSession = Container.userSession()
|
||||
let playbackURL: URL
|
||||
let streamType: StreamType
|
||||
|
||||
|
@ -13,12 +13,11 @@ import VLCUI
|
||||
|
||||
extension MediaStream {
|
||||
|
||||
// TODO: Localize
|
||||
static var none: MediaStream = .init(displayTitle: "None", index: -1)
|
||||
static var none: MediaStream = .init(displayTitle: L10n.none, index: -1)
|
||||
|
||||
var asPlaybackChild: VLCVideoPlayer.PlaybackChild? {
|
||||
guard let deliveryURL else { return nil }
|
||||
let client = Container.userSession.callAsFunction().client
|
||||
let client = Container.userSession().client
|
||||
let deliveryPath = deliveryURL.removingFirst(if: client.configuration.url.absoluteString.last == "/")
|
||||
|
||||
let fullURL = client.fullURL(with: deliveryPath)
|
||||
@ -46,122 +45,117 @@ extension MediaStream {
|
||||
(width ?? 0) > 1900 && type == .video
|
||||
}
|
||||
|
||||
var size: String? {
|
||||
guard let height, let width else { return nil }
|
||||
return "\(width)x\(height)"
|
||||
}
|
||||
|
||||
// MARK: Property groups
|
||||
|
||||
var metadataProperties: [TextPair] {
|
||||
var properties: [TextPair] = []
|
||||
|
||||
if let value = type {
|
||||
properties.append(.init(displayTitle: "Type", subtitle: value.rawValue))
|
||||
properties.append(.init(title: "Type", subtitle: value.rawValue))
|
||||
}
|
||||
|
||||
if let value = codec {
|
||||
properties.append(.init(displayTitle: "Codec", subtitle: value))
|
||||
properties.append(.init(title: "Codec", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = codecTag {
|
||||
properties.append(.init(displayTitle: "Codec Tag", subtitle: value))
|
||||
properties.append(.init(title: "Codec Tag", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = language {
|
||||
properties.append(.init(displayTitle: "Language", subtitle: value))
|
||||
properties.append(.init(title: "Language", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = timeBase {
|
||||
properties.append(.init(displayTitle: "Time Base", subtitle: value))
|
||||
properties.append(.init(title: "Time Base", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = codecTimeBase {
|
||||
properties.append(.init(displayTitle: "Codec Time Base", subtitle: value))
|
||||
properties.append(.init(title: "Codec Time Base", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = videoRange {
|
||||
properties.append(.init(displayTitle: "Video Range", subtitle: value))
|
||||
properties.append(.init(title: "Video Range", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = isInterlaced {
|
||||
properties.append(.init(displayTitle: "Interlaced", subtitle: value.description))
|
||||
properties.append(.init(title: "Interlaced", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = isAVC {
|
||||
properties.append(.init(displayTitle: "AVC", subtitle: value.description))
|
||||
properties.append(.init(title: "AVC", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = channelLayout {
|
||||
properties.append(.init(displayTitle: "Channel Layout", subtitle: value))
|
||||
properties.append(.init(title: "Channel Layout", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = bitRate {
|
||||
properties.append(.init(displayTitle: "Bitrate", subtitle: value.description))
|
||||
properties.append(.init(title: "Bitrate", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = bitDepth {
|
||||
properties.append(.init(displayTitle: "Bit Depth", subtitle: value.description))
|
||||
properties.append(.init(title: "Bit Depth", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = refFrames {
|
||||
properties.append(.init(displayTitle: "Reference Frames", subtitle: value.description))
|
||||
properties.append(.init(title: "Reference Frames", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = packetLength {
|
||||
properties.append(.init(displayTitle: "Packet Length", subtitle: value.description))
|
||||
properties.append(.init(title: "Packet Length", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = channels {
|
||||
properties.append(.init(displayTitle: "Channels", subtitle: value.description))
|
||||
properties.append(.init(title: "Channels", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = sampleRate {
|
||||
properties.append(.init(displayTitle: "Sample Rate", subtitle: value.description))
|
||||
properties.append(.init(title: "Sample Rate", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = isDefault {
|
||||
properties.append(.init(displayTitle: "Default", subtitle: value.description))
|
||||
properties.append(.init(title: "Default", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = isForced {
|
||||
properties.append(.init(displayTitle: "Forced", subtitle: value.description))
|
||||
properties.append(.init(title: "Forced", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = averageFrameRate {
|
||||
properties.append(.init(displayTitle: "Average Frame Rate", subtitle: value.description))
|
||||
properties.append(.init(title: "Average Frame Rate", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = realFrameRate {
|
||||
properties.append(.init(displayTitle: "Real Frame Rate", subtitle: value.description))
|
||||
properties.append(.init(title: "Real Frame Rate", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = profile {
|
||||
properties.append(.init(displayTitle: "Profile", subtitle: value))
|
||||
properties.append(.init(title: "Profile", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = aspectRatio {
|
||||
properties.append(.init(displayTitle: "Aspect Ratio", subtitle: value))
|
||||
properties.append(.init(title: "Aspect Ratio", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = index {
|
||||
properties.append(.init(displayTitle: "Index", subtitle: value.description))
|
||||
properties.append(.init(title: "Index", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = score {
|
||||
properties.append(.init(displayTitle: "Score", subtitle: value.description))
|
||||
properties.append(.init(title: "Score", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = pixelFormat {
|
||||
properties.append(.init(displayTitle: "Pixel Format", subtitle: value))
|
||||
properties.append(.init(title: "Pixel Format", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = level {
|
||||
properties.append(.init(displayTitle: "Level", subtitle: value.description))
|
||||
properties.append(.init(title: "Level", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = isAnamorphic {
|
||||
properties.append(.init(displayTitle: "Anamorphic", subtitle: value.description))
|
||||
properties.append(.init(title: "Anamorphic", subtitle: value.description))
|
||||
}
|
||||
|
||||
return properties
|
||||
@ -171,19 +165,19 @@ extension MediaStream {
|
||||
var properties: [TextPair] = []
|
||||
|
||||
if let value = colorRange {
|
||||
properties.append(.init(displayTitle: "Range", subtitle: value))
|
||||
properties.append(.init(title: "Range", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = colorSpace {
|
||||
properties.append(.init(displayTitle: "Space", subtitle: value))
|
||||
properties.append(.init(title: "Space", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = colorTransfer {
|
||||
properties.append(.init(displayTitle: "Transfer", subtitle: value))
|
||||
properties.append(.init(title: "Transfer", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = colorPrimaries {
|
||||
properties.append(.init(displayTitle: "Primaries", subtitle: value))
|
||||
properties.append(.init(title: "Primaries", subtitle: value))
|
||||
}
|
||||
|
||||
return properties
|
||||
@ -193,27 +187,27 @@ extension MediaStream {
|
||||
var properties: [TextPair] = []
|
||||
|
||||
if let value = isExternal {
|
||||
properties.append(.init(displayTitle: "External", subtitle: value.description))
|
||||
properties.append(.init(title: "External", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = deliveryMethod {
|
||||
properties.append(.init(displayTitle: "Delivery Method", subtitle: value.rawValue))
|
||||
properties.append(.init(title: "Delivery Method", subtitle: value.rawValue))
|
||||
}
|
||||
|
||||
if let value = deliveryURL {
|
||||
properties.append(.init(displayTitle: "URL", subtitle: value))
|
||||
properties.append(.init(title: "URL", subtitle: value))
|
||||
}
|
||||
|
||||
if let value = deliveryURL {
|
||||
properties.append(.init(displayTitle: "External URL", subtitle: value.description))
|
||||
properties.append(.init(title: "External URL", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = isTextSubtitleStream {
|
||||
properties.append(.init(displayTitle: "Text Subtitle", subtitle: value.description))
|
||||
properties.append(.init(title: "Text Subtitle", subtitle: value.description))
|
||||
}
|
||||
|
||||
if let value = path {
|
||||
properties.append(.init(displayTitle: "Path", subtitle: value))
|
||||
properties.append(.init(title: "Path", subtitle: value))
|
||||
}
|
||||
|
||||
return properties
|
||||
@ -222,6 +216,7 @@ extension MediaStream {
|
||||
|
||||
extension [MediaStream] {
|
||||
|
||||
// TODO: explain why adjustment is necessary
|
||||
func adjustExternalSubtitleIndexes(audioStreamCount: Int) -> [MediaStream] {
|
||||
guard allSatisfy({ $0.type == .subtitle }) else { return self }
|
||||
let embeddedSubtitleCount = filter { !($0.isExternal ?? false) }.count
|
||||
@ -239,6 +234,7 @@ extension [MediaStream] {
|
||||
return mediaStreams
|
||||
}
|
||||
|
||||
// TODO: explain why adjustment is necessary
|
||||
func adjustAudioForExternalSubtitles(externalMediaStreamCount: Int) -> [MediaStream] {
|
||||
guard allSatisfy({ $0.type == .audio }) else { return self }
|
||||
|
||||
@ -254,22 +250,22 @@ extension [MediaStream] {
|
||||
}
|
||||
|
||||
var has4KVideo: Bool {
|
||||
first(where: { $0.is4kVideo }) != nil
|
||||
oneSatisfies { $0.is4kVideo }
|
||||
}
|
||||
|
||||
var has51AudioChannelLayout: Bool {
|
||||
first(where: { $0.is51AudioChannelLayout }) != nil
|
||||
oneSatisfies { $0.is51AudioChannelLayout }
|
||||
}
|
||||
|
||||
var has71AudioChannelLayout: Bool {
|
||||
first(where: { $0.is71AudioChannelLayout }) != nil
|
||||
oneSatisfies { $0.is71AudioChannelLayout }
|
||||
}
|
||||
|
||||
var hasHDVideo: Bool {
|
||||
first(where: { $0.isHDVideo }) != nil
|
||||
oneSatisfies { $0.isHDVideo }
|
||||
}
|
||||
|
||||
var hasSubtitles: Bool {
|
||||
first(where: { $0.type == .subtitle }) != nil
|
||||
oneSatisfies { $0.type == .subtitle }
|
||||
}
|
||||
}
|
||||
|
@ -16,11 +16,10 @@ extension NameGuidPair: Displayable {
|
||||
}
|
||||
}
|
||||
|
||||
extension NameGuidPair: LibraryParent {}
|
||||
// TODO: strong type studios and implement as `LibraryParent`
|
||||
extension NameGuidPair: LibraryParent {
|
||||
|
||||
extension NameGuidPair {
|
||||
|
||||
var filter: ItemFilters.Filter {
|
||||
.init(displayTitle: displayTitle, id: id, filterName: displayTitle)
|
||||
var libraryType: BaseItemKind? {
|
||||
.studio
|
||||
}
|
||||
}
|
||||
|
@ -9,9 +9,10 @@
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
typealias APISortOrder = JellyfinAPI.SortOrder
|
||||
// Necessary to handle conflict with Foundation.SortOrder
|
||||
typealias ItemSortOrder = JellyfinAPI.SortOrder
|
||||
|
||||
extension APISortOrder: Displayable {
|
||||
extension ItemSortOrder: Displayable {
|
||||
// TODO: Localize
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
@ -23,9 +24,13 @@ extension APISortOrder: Displayable {
|
||||
}
|
||||
}
|
||||
|
||||
extension APISortOrder {
|
||||
extension ItemSortOrder: ItemFilter {
|
||||
|
||||
var filter: ItemFilters.Filter {
|
||||
.init(displayTitle: displayTitle, id: rawValue, filterName: rawValue)
|
||||
var value: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
init(from anyFilter: AnyItemFilter) {
|
||||
self.init(rawValue: anyFilter.value)!
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Stinsen
|
||||
import SwiftUI
|
||||
|
||||
extension NavigationCoordinatable {
|
||||
|
||||
@ -14,3 +15,10 @@ extension NavigationCoordinatable {
|
||||
NavigationViewCoordinator(self)
|
||||
}
|
||||
}
|
||||
|
||||
extension NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
|
||||
convenience init<Content: View>(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.init(BasicNavigationViewCoordinator(content))
|
||||
}
|
||||
}
|
||||
|
43
Shared/Extensions/Sequence.swift
Normal file
43
Shared/Extensions/Sequence.swift
Normal 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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Sequence {
|
||||
|
||||
func compacted<Value>(using keyPath: KeyPath<Element, Value?>) -> [Element] {
|
||||
filter { $0[keyPath: keyPath] != nil }
|
||||
}
|
||||
|
||||
func intersection<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] {
|
||||
filter { other.contains($0[keyPath: keyPath]) }
|
||||
}
|
||||
|
||||
/// Returns the elements of the sequence, sorted by comparing values
|
||||
/// at the given `KeyPath` of `Element`.
|
||||
func sorted<Key: Comparable>(using keyPath: KeyPath<Element, Key>) -> [Element] {
|
||||
sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] })
|
||||
}
|
||||
|
||||
func subtracting<Value: Equatable>(_ other: some Sequence<Value>, using keyPath: KeyPath<Element, Value>) -> [Element] {
|
||||
filter { !other.contains($0[keyPath: keyPath]) }
|
||||
}
|
||||
}
|
||||
|
||||
extension Sequence where Element: Equatable {
|
||||
|
||||
/// Returns an array containing the elements of the sequence that
|
||||
/// are also within the given sequence.
|
||||
func intersection(_ other: some Sequence<Element>) -> [Element] {
|
||||
filter { other.contains($0) }
|
||||
}
|
||||
|
||||
func subtracting(_ other: some Sequence<Element>) -> [Element] {
|
||||
filter { !other.contains($0) }
|
||||
}
|
||||
}
|
@ -9,6 +9,7 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// TODO: Remove this and strongly type instances if it makes sense.
|
||||
extension String: Displayable {
|
||||
|
||||
var displayTitle: String {
|
||||
@ -16,15 +17,12 @@ extension String: Displayable {
|
||||
}
|
||||
}
|
||||
|
||||
extension String: Identifiable {
|
||||
|
||||
public var id: String {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
|
||||
static func + (lhs: String, rhs: Character) -> String {
|
||||
lhs.appending(rhs)
|
||||
}
|
||||
|
||||
func appending(_ element: String) -> String {
|
||||
self + element
|
||||
}
|
||||
@ -63,20 +61,11 @@ extension String {
|
||||
} catch { return self }
|
||||
}
|
||||
|
||||
func leftPad(toWidth width: Int, withString string: String?) -> String {
|
||||
let paddingString = string ?? " "
|
||||
func leftPad(maxWidth width: Int, with character: Character) -> String {
|
||||
guard count < width else { return self }
|
||||
|
||||
if self.count >= width {
|
||||
return self
|
||||
}
|
||||
|
||||
let remainingLength: Int = width - self.count
|
||||
var padString = String()
|
||||
for _ in 0 ..< remainingLength {
|
||||
padString += paddingString
|
||||
}
|
||||
|
||||
return "\(padString)\(self)"
|
||||
let padding = String(repeating: character, count: width - count)
|
||||
return padding + self
|
||||
}
|
||||
|
||||
var text: Text {
|
||||
@ -84,24 +73,9 @@ extension String {
|
||||
}
|
||||
|
||||
var initials: String {
|
||||
let initials = self.split(separator: " ").compactMap(\.first)
|
||||
return String(initials)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var filter: ItemFilters.Filter {
|
||||
.init(displayTitle: self, id: self, filterName: self)
|
||||
split(separator: " ")
|
||||
.compactMap(\.first)
|
||||
.reduce("", +)
|
||||
}
|
||||
|
||||
static var emptyDash = "--"
|
||||
|
@ -6,11 +6,17 @@
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
extension Set {
|
||||
extension Task {
|
||||
|
||||
func sorted<Key: Comparable>(using keyPath: KeyPath<Element, Key>) -> [Element] {
|
||||
sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] })
|
||||
@inlinable
|
||||
func asAnyCancellable() -> AnyCancellable {
|
||||
AnyCancellable(cancel)
|
||||
}
|
||||
|
||||
func store(in set: inout Set<AnyCancellable>) {
|
||||
set.insert(asAnyCancellable())
|
||||
}
|
||||
}
|
@ -36,14 +36,4 @@ extension UIApplication {
|
||||
func setAppearance(_ newAppearance: UIUserInterfaceStyle) {
|
||||
keyWindow?.overrideUserInterfaceStyle = newAppearance
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func setNavigationBackButtonAccentColor(_ newColor: UIColor) {
|
||||
let config = UIImage.SymbolConfiguration(paletteColors: [newColor.overlayColor, newColor])
|
||||
let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill", withConfiguration: config)
|
||||
let barAppearance = UINavigationBar.appearance()
|
||||
barAppearance.backIndicatorImage = backButtonBackgroundImage
|
||||
barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ extension UIDevice {
|
||||
current.identifierForVendor!.uuidString
|
||||
}
|
||||
|
||||
static var isIPad: Bool {
|
||||
static var isPad: Bool {
|
||||
current.userInterfaceIdiom == .pad
|
||||
}
|
||||
|
||||
@ -31,7 +31,7 @@ extension UIDevice {
|
||||
#if os(tvOS)
|
||||
"tvOS"
|
||||
#else
|
||||
if UIDevice.isIPad {
|
||||
if UIDevice.isPad {
|
||||
return "iPadOS"
|
||||
} else {
|
||||
return "iOS"
|
||||
@ -45,7 +45,7 @@ extension UIDevice {
|
||||
}
|
||||
|
||||
static var isLandscape: Bool {
|
||||
isIPad || current.orientation.isLandscape
|
||||
isPad || current.orientation.isLandscape
|
||||
}
|
||||
|
||||
static func feedback(_ type: UINotificationFeedbackGenerator.FeedbackType) {
|
||||
|
@ -17,10 +17,4 @@ extension UIScreen {
|
||||
func scale(_ x: CGFloat) -> Int {
|
||||
Int(nativeScale * x)
|
||||
}
|
||||
|
||||
func maxChildren(width: CGFloat, height: CGFloat) -> Int {
|
||||
let screenSize = bounds.height * bounds.width
|
||||
let itemSize = width * height
|
||||
return Int(screenSize / itemSize)
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIScrollView {
|
||||
|
||||
func scrollToTop(animated: Bool = true) {
|
||||
let desiredOffset = CGPoint(x: 0, y: 0)
|
||||
setContentOffset(desiredOffset, animated: animated)
|
||||
}
|
||||
}
|
64
Shared/Extensions/ViewExtensions/Backport.swift
Normal file
64
Shared/Extensions/ViewExtensions/Backport.swift
Normal file
@ -0,0 +1,64 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct Backport<Content> {
|
||||
|
||||
let content: Content
|
||||
}
|
||||
|
||||
extension Backport where Content: View {
|
||||
|
||||
@ViewBuilder
|
||||
func lineLimit(_ limit: Int, reservesSpace: Bool = false) -> some View {
|
||||
if #available(iOS 16, tvOS 16, *) {
|
||||
content
|
||||
.lineLimit(limit, reservesSpace: reservesSpace)
|
||||
} else {
|
||||
ZStack(alignment: .top) {
|
||||
Text(String(repeating: "\n", count: limit - 1))
|
||||
|
||||
content
|
||||
.lineLimit(limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
// TODO: - remove comment when migrated away from Stinsen
|
||||
//
|
||||
// This doesn't seem to work on device, but does in the simulator.
|
||||
// It is assumed that because Stinsen adds a lot of views that the
|
||||
// PreferencesView isn't in the right place in the VC chain so that
|
||||
// it can apply the settings, even SwiftUI's deferment.
|
||||
@available(iOS 15.0, *)
|
||||
@ViewBuilder
|
||||
func defersSystemGestures(on edges: Edge.Set) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.defersSystemGestures(on: edges)
|
||||
} else {
|
||||
content
|
||||
.preferredScreenEdgesDeferringSystemGestures(edges.asUIRectEdge)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func persistentSystemOverlays(_ visibility: Visibility) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.persistentSystemOverlays(visibility)
|
||||
} else {
|
||||
content
|
||||
.prefersHomeIndicatorAutoHidden(visibility == .hidden ? true : false)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AfterLastDisappearModifier: ViewModifier {
|
||||
|
||||
@State
|
||||
private var lastDisappear: Date? = nil
|
||||
|
||||
let action: (TimeInterval) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onAppear {
|
||||
guard let lastDisappear else { return }
|
||||
let interval = Date.now.timeIntervalSince(lastDisappear)
|
||||
action(interval)
|
||||
}
|
||||
.onDisappear {
|
||||
lastDisappear = Date.now
|
||||
}
|
||||
}
|
||||
}
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct OnFinalDisappearModifier: ViewModifier {
|
||||
|
||||
@StateObject
|
||||
private var observer: Observer
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
_observer = StateObject(wrappedValue: Observer(action: action))
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
private class Observer: ObservableObject {
|
||||
|
||||
private let action: () -> Void
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
self.action = action
|
||||
}
|
||||
|
||||
deinit {
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
@ -8,16 +8,19 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PaddingMultiplierModifier: ViewModifier {
|
||||
struct OnFirstAppearModifier: ViewModifier {
|
||||
|
||||
let edges: Edge.Set
|
||||
let multiplier: Int
|
||||
@State
|
||||
private var didAppear = false
|
||||
|
||||
let action: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.if(multiplier > 0) { view in
|
||||
view.padding()
|
||||
.padding(multiplier: multiplier - 1, edges)
|
||||
.onAppear {
|
||||
guard !didAppear else { return }
|
||||
didAppear = true
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PaletteOverlayRenderingModifier: ViewModifier {
|
||||
|
||||
@Environment(\.accentColor)
|
||||
private var accentColor
|
||||
|
||||
let color: Color?
|
||||
|
||||
private var _color: Color {
|
||||
color ?? accentColor
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(_color.overlayColor, _color)
|
||||
}
|
||||
}
|
@ -19,7 +19,8 @@ extension View {
|
||||
AnyView(self)
|
||||
}
|
||||
|
||||
func inverseMask(alignment: Alignment = .center, _ content: @escaping () -> some View) -> some View {
|
||||
// TODO: rename `invertedMask`?
|
||||
func inverseMask(alignment: Alignment = .center, @ViewBuilder _ content: @escaping () -> some View) -> some View {
|
||||
mask(alignment: alignment) {
|
||||
content()
|
||||
.foregroundColor(.black)
|
||||
@ -29,10 +30,11 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
// From: https://www.avanderlee.com/swiftui/conditional-view-modifier/
|
||||
/// - Important: Do *not* use this modifier for dynamically showing/hiding views.
|
||||
/// Instead, use a native `if` statement.
|
||||
@ViewBuilder
|
||||
@inlinable
|
||||
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
func `if`<Content: View>(_ condition: Bool, @ViewBuilder transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
@ -40,9 +42,15 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
/// - Important: Do *not* use this modifier for dynamically showing/hiding views.
|
||||
/// Instead, use a native `if/else` statement.
|
||||
@ViewBuilder
|
||||
@inlinable
|
||||
func `if`<Content: View>(_ condition: Bool, transformIf: (Self) -> Content, transformElse: (Self) -> Content) -> some View {
|
||||
func `if`<Content: View>(
|
||||
_ condition: Bool,
|
||||
@ViewBuilder transformIf: (Self) -> Content,
|
||||
@ViewBuilder transformElse: (Self) -> Content
|
||||
) -> some View {
|
||||
if condition {
|
||||
transformIf(self)
|
||||
} else {
|
||||
@ -50,32 +58,60 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Don't apply corner radius on tvOS because buttons handle themselves, add new modifier for setting corner radius of poster type
|
||||
/// - Important: Do *not* use this modifier for dynamically showing/hiding views.
|
||||
/// Instead, use a native `if let` statement.
|
||||
@ViewBuilder
|
||||
@inlinable
|
||||
func ifLet<Value, Content: View>(
|
||||
_ value: Value?,
|
||||
@ViewBuilder transform: (Self, Value) -> Content
|
||||
) -> some View {
|
||||
if let value {
|
||||
transform(self, value)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// - Important: Do *not* use this modifier for dynamically showing/hiding views.
|
||||
/// Instead, use a native `if let/else` statement.
|
||||
@ViewBuilder
|
||||
@inlinable
|
||||
func ifLet<Value, Content: View>(
|
||||
_ value: Value?,
|
||||
@ViewBuilder transformIf: (Self, Value) -> Content,
|
||||
@ViewBuilder transformElse: (Self) -> Content
|
||||
) -> some View {
|
||||
if let value {
|
||||
transformIf(self, value)
|
||||
} else {
|
||||
transformElse(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the aspect ratio and corner radius for the given `PosterType`
|
||||
@ViewBuilder
|
||||
func posterStyle(_ type: PosterType) -> some View {
|
||||
switch type {
|
||||
case .portrait:
|
||||
aspectRatio(2 / 3, contentMode: .fit)
|
||||
aspectRatio(2 / 3, contentMode: .fill)
|
||||
#if !os(tvOS)
|
||||
.cornerRadius(ratio: 0.0375, of: \.width)
|
||||
#endif
|
||||
case .landscape:
|
||||
aspectRatio(1.77, contentMode: .fit)
|
||||
aspectRatio(1.77, contentMode: .fill)
|
||||
#if !os(tvOS)
|
||||
.cornerRadius(ratio: 1 / 30, of: \.width)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: switch to padding(multiplier: 2)
|
||||
// TODO: remove
|
||||
@inlinable
|
||||
func padding2(_ edges: Edge.Set = .all) -> some View {
|
||||
padding(edges).padding(edges)
|
||||
}
|
||||
|
||||
/// Applies the default system padding a number of times with a multiplier
|
||||
func padding(multiplier: Int, _ edges: Edge.Set = .all) -> some View {
|
||||
precondition(multiplier > 0, "Multiplier must be > 0")
|
||||
|
||||
return modifier(PaddingMultiplierModifier(edges: edges, multiplier: multiplier))
|
||||
}
|
||||
|
||||
func scrollViewOffset(_ scrollViewOffset: Binding<CGFloat>) -> some View {
|
||||
modifier(ScrollViewOffsetModifier(scrollViewOffset: scrollViewOffset))
|
||||
}
|
||||
@ -101,7 +137,7 @@ extension View {
|
||||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||
}
|
||||
|
||||
/// Apply a corner radius as a ratio of a side of the view's size
|
||||
/// Apply a corner radius as a ratio of a view's side
|
||||
func cornerRadius(ratio: CGFloat, of side: KeyPath<CGSize, CGFloat>, corners: UIRectCorner = .allCorners) -> some View {
|
||||
modifier(RatioCornerRadiusModifier(corners: corners, ratio: ratio, side: side))
|
||||
}
|
||||
@ -116,6 +152,14 @@ extension View {
|
||||
.onPreferenceChange(FramePreferenceKey.self, perform: onChange)
|
||||
}
|
||||
|
||||
func frame(_ binding: Binding<CGRect>) -> some View {
|
||||
onFrameChanged { newFrame in
|
||||
binding.wrappedValue = newFrame
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: have x/y tracked binding
|
||||
|
||||
func onLocationChanged(_ onChange: @escaping (CGPoint) -> Void) -> some View {
|
||||
background {
|
||||
GeometryReader { reader in
|
||||
@ -129,6 +173,14 @@ extension View {
|
||||
.onPreferenceChange(LocationPreferenceKey.self, perform: onChange)
|
||||
}
|
||||
|
||||
func location(_ binding: Binding<CGPoint>) -> some View {
|
||||
onLocationChanged { newLocation in
|
||||
binding.wrappedValue = newLocation
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: have width/height tracked binding
|
||||
|
||||
func onSizeChanged(_ onChange: @escaping (CGSize) -> Void) -> some View {
|
||||
background {
|
||||
GeometryReader { reader in
|
||||
@ -139,22 +191,22 @@ extension View {
|
||||
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
|
||||
}
|
||||
|
||||
func size(_ binding: Binding<CGSize>) -> some View {
|
||||
onSizeChanged { newSize in
|
||||
binding.wrappedValue = newSize
|
||||
}
|
||||
}
|
||||
|
||||
func copy<Value>(modifying keyPath: WritableKeyPath<Self, Value>, with newValue: Value) -> Self {
|
||||
var copy = self
|
||||
copy[keyPath: keyPath] = newValue
|
||||
return copy
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func hideSystemOverlays() -> some View {
|
||||
if #available(iOS 16, tvOS 16, *) {
|
||||
persistentSystemOverlays(.hidden)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: rename isVisible
|
||||
|
||||
/// - Important: Do not use this to add or remove a view from the view heirarchy.
|
||||
/// Use a conditional statement instead.
|
||||
@inlinable
|
||||
func visible(_ isVisible: Bool) -> some View {
|
||||
opacity(isVisible ? 1 : 0)
|
||||
@ -166,9 +218,13 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
func accentSymbolRendering(accentColor: Color = Defaults[.accentColor]) -> some View {
|
||||
symbolRenderingMode(.palette)
|
||||
.foregroundStyle(accentColor.overlayColor, accentColor)
|
||||
/// Applies the `.palette` symbol rendering mode and a foreground style
|
||||
/// where the primary style is the passed `Color`'s `overlayColor` and the
|
||||
/// secondary style is the passed `Color`.
|
||||
///
|
||||
/// If `color == nil`, then `accentColor` from the environment is used.
|
||||
func paletteOverlayRendering(color: Color? = nil) -> some View {
|
||||
modifier(PaletteOverlayRenderingModifier(color: color))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -184,6 +240,7 @@ extension View {
|
||||
modifier(AttributeViewModifier(style: style))
|
||||
}
|
||||
|
||||
// TODO: rename `blurredFullScreenCover`
|
||||
func blurFullScreenCover(
|
||||
isPresented: Binding<Bool>,
|
||||
onDismiss: (() -> Void)? = nil,
|
||||
@ -203,4 +260,45 @@ extension View {
|
||||
func onScenePhase(_ phase: ScenePhase, _ action: @escaping () -> Void) -> some View {
|
||||
modifier(ScenePhaseChangeModifier(phase: phase, action: action))
|
||||
}
|
||||
|
||||
func edgePadding(_ edges: Edge.Set = .all) -> some View {
|
||||
padding(edges, EdgeInsets.defaultEdgePadding)
|
||||
}
|
||||
|
||||
var backport: Backport<Self> {
|
||||
Backport(content: self)
|
||||
}
|
||||
|
||||
/// Perform an action on the final disappearance of a `View`.
|
||||
func onFinalDisappear(perform action: @escaping () -> Void) -> some View {
|
||||
modifier(OnFinalDisappearModifier(action: action))
|
||||
}
|
||||
|
||||
/// Perform an action before the first appearance of a `View`.
|
||||
func onFirstAppear(perform action: @escaping () -> Void) -> some View {
|
||||
modifier(OnFirstAppearModifier(action: action))
|
||||
}
|
||||
|
||||
/// Perform an action as a view appears given the time interval
|
||||
/// from when this view last disappeared.
|
||||
func afterLastDisappear(perform action: @escaping (TimeInterval) -> Void) -> some View {
|
||||
modifier(AfterLastDisappearModifier(action: action))
|
||||
}
|
||||
|
||||
func topBarTrailing(@ViewBuilder content: @escaping () -> some View) -> some View {
|
||||
toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onNotification(_ name: NSNotification.Name, perform action: @escaping () -> Void) -> some View {
|
||||
modifier(
|
||||
OnReceiveNotificationModifier(
|
||||
notification: name,
|
||||
onReceive: action
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,11 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EnumPicker<EnumType: CaseIterable & Displayable & Hashable>: View {
|
||||
/// A `View` that automatically generates a SwiftUI `Picker` if `Element` conforms to `CaseIterable`.
|
||||
///
|
||||
/// If `Element` is optional, an additional `none` value is added to select `nil` that can be customized
|
||||
/// by `.noneStyle()`.
|
||||
struct CaseIterablePicker<Element: CaseIterable & Displayable & Hashable>: View {
|
||||
|
||||
enum NoneStyle: Displayable {
|
||||
|
||||
@ -21,60 +25,62 @@ struct EnumPicker<EnumType: CaseIterable & Displayable & Hashable>: View {
|
||||
case .text:
|
||||
return L10n.none
|
||||
case let .dash(length):
|
||||
precondition(length >= 1, "Dash must have length of at least 1.")
|
||||
assert(length >= 1, "Dash must have length of at least 1.")
|
||||
return String(repeating: "-", count: length)
|
||||
case let .custom(text):
|
||||
precondition(!text.isEmpty, "Custom text must have length of at least 1.")
|
||||
assert(text.isNotEmpty, "Custom text must have length of at least 1.")
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Binding
|
||||
private var selection: EnumType?
|
||||
private var selection: Element?
|
||||
|
||||
private let title: String
|
||||
private let hasNil: Bool
|
||||
private let hasNone: Bool
|
||||
private var noneStyle: NoneStyle
|
||||
|
||||
var body: some View {
|
||||
Picker(title, selection: $selection) {
|
||||
|
||||
if hasNil {
|
||||
if hasNone {
|
||||
Text(noneStyle.displayTitle)
|
||||
.tag(nil as EnumType?)
|
||||
.tag(nil as Element?)
|
||||
}
|
||||
|
||||
ForEach(EnumType.allCases.asArray, id: \.hashValue) {
|
||||
ForEach(Element.allCases.asArray, id: \.hashValue) {
|
||||
Text($0.displayTitle)
|
||||
.tag($0 as EnumType?)
|
||||
.tag($0 as Element?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EnumPicker {
|
||||
extension CaseIterablePicker {
|
||||
|
||||
init(title: String, selection: Binding<EnumType?>) {
|
||||
self.title = title
|
||||
self._selection = selection
|
||||
self.hasNil = true
|
||||
self.noneStyle = .text
|
||||
init(title: String, selection: Binding<Element?>) {
|
||||
self.init(
|
||||
selection: selection,
|
||||
title: title,
|
||||
hasNone: true,
|
||||
noneStyle: .text
|
||||
)
|
||||
}
|
||||
|
||||
init(title: String, selection: Binding<EnumType>) {
|
||||
init(title: String, selection: Binding<Element>) {
|
||||
self.title = title
|
||||
|
||||
let binding = Binding<EnumType?> {
|
||||
let binding = Binding<Element?> {
|
||||
selection.wrappedValue
|
||||
} set: { newValue, _ in
|
||||
assert(newValue != nil, "Should not have nil new value with non-optional binding")
|
||||
precondition(newValue != nil, "Should not have nil new value with non-optional binding")
|
||||
selection.wrappedValue = newValue!
|
||||
}
|
||||
|
||||
self._selection = binding
|
||||
|
||||
self.hasNil = false
|
||||
self.hasNone = false
|
||||
self.noneStyle = .text
|
||||
}
|
||||
|
@ -1,256 +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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
|
||||
enum DeviceProfileBuilder {
|
||||
|
||||
static func buildProfile(for type: VideoPlayerType, maxBitrate: Int? = nil) -> DeviceProfile {
|
||||
let maxStreamingBitrate = maxBitrate
|
||||
let maxStaticBitrate = maxBitrate
|
||||
let musicStreamingTranscodingBitrate = maxBitrate
|
||||
var directPlayProfiles: [DirectPlayProfile] = []
|
||||
var transcodingProfiles: [TranscodingProfile] = []
|
||||
var codecProfiles: [CodecProfile] = []
|
||||
var subtitleProfiles: [SubtitleProfile] = []
|
||||
|
||||
switch type {
|
||||
|
||||
case .swiftfin:
|
||||
func buildProfileSwiftfin() {
|
||||
// Build direct play profiles
|
||||
directPlayProfiles = [
|
||||
// Just make one profile because if VLCKit can't decode it in a certain container, ffmpeg probably can't decode it for
|
||||
// transcode either
|
||||
DirectPlayProfile(
|
||||
// No need to list containers or videocodecs since if jellyfin server can detect it/ffmpeg can decode it, so can
|
||||
// VLCKit
|
||||
// However, list audiocodecs because ffmpeg can decode TrueHD/mlp but VLCKit cannot
|
||||
audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le,pcm_u8,pcm_alaw,pcm_mulaw,pcm_bluray,pcm_dvd,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb",
|
||||
type: .video
|
||||
),
|
||||
]
|
||||
|
||||
// Build transcoding profiles
|
||||
// The only cases where transcoding should occur:
|
||||
// 1) TrueHD/mlp audio
|
||||
// 2) When server forces transcode for bitrate reasons
|
||||
transcodingProfiles = [TranscodingProfile(
|
||||
audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1",
|
||||
// no PCM,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb in mp4
|
||||
isBreakOnNonKeyFrames: true,
|
||||
container: "mp4",
|
||||
context: .streaming,
|
||||
maxAudioChannels: "8",
|
||||
minSegments: 2,
|
||||
protocol: "hls",
|
||||
type: .video,
|
||||
videoCodec: "hevc,h264,av1,vp9,vc1,mpeg4,h263,mpeg2video,mpeg1video,mjpeg" // vp8,msmpeg4v3,msmpeg4v2,msmpeg4v1,theora,ffv1,flv1,wmv3,wmv2,wmv1
|
||||
// not supported in mp4
|
||||
)]
|
||||
|
||||
// Create subtitle profiles
|
||||
subtitleProfiles = [
|
||||
SubtitleProfile(format: "pgssub", method: .embed), // *pgs* normalized to pgssub; includes sup
|
||||
SubtitleProfile(format: "dvdsub", method: .embed),
|
||||
// *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case?
|
||||
SubtitleProfile(format: "subrip", method: .embed), // srt
|
||||
SubtitleProfile(format: "ass", method: .embed),
|
||||
SubtitleProfile(format: "ssa", method: .embed),
|
||||
SubtitleProfile(format: "vtt", method: .embed), // webvtt
|
||||
SubtitleProfile(format: "mov_text", method: .embed), // MPEG-4 Timed Text
|
||||
SubtitleProfile(format: "ttml", method: .embed),
|
||||
SubtitleProfile(format: "text", method: .embed), // txt
|
||||
SubtitleProfile(format: "dvbsub", method: .embed),
|
||||
// dvb_subtitle normalized to dvbsub; burned in during transcode regardless?
|
||||
SubtitleProfile(format: "libzvbi_teletextdec", method: .embed), // dvb_teletext
|
||||
SubtitleProfile(format: "xsub", method: .embed),
|
||||
SubtitleProfile(format: "vplayer", method: .embed),
|
||||
SubtitleProfile(format: "subviewer", method: .embed),
|
||||
SubtitleProfile(format: "subviewer1", method: .embed),
|
||||
SubtitleProfile(format: "sami", method: .embed), // SMI
|
||||
SubtitleProfile(format: "realtext", method: .embed),
|
||||
SubtitleProfile(format: "pjs", method: .embed), // Phoenix Subtitle
|
||||
SubtitleProfile(format: "mpl2", method: .embed),
|
||||
SubtitleProfile(format: "jacosub", method: .embed),
|
||||
SubtitleProfile(format: "cc_dec", method: .embed), // eia_608
|
||||
// Can be passed as external files; ones that jellyfin can encode to must come first
|
||||
SubtitleProfile(format: "subrip", method: .external), // srt
|
||||
SubtitleProfile(format: "ttml", method: .external),
|
||||
SubtitleProfile(format: "vtt", method: .external), // webvtt
|
||||
SubtitleProfile(format: "ass", method: .external),
|
||||
SubtitleProfile(format: "ssa", method: .external),
|
||||
SubtitleProfile(format: "pgssub", method: .external),
|
||||
SubtitleProfile(format: "text", method: .external), // txt
|
||||
SubtitleProfile(format: "dvbsub", method: .external), // dvb_subtitle normalized to dvbsub
|
||||
SubtitleProfile(format: "libzvbi_teletextdec", method: .external), // dvb_teletext
|
||||
SubtitleProfile(format: "dvdsub", method: .external),
|
||||
// *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case?
|
||||
SubtitleProfile(format: "xsub", method: .external),
|
||||
SubtitleProfile(format: "vplayer", method: .external),
|
||||
SubtitleProfile(format: "subviewer", method: .external),
|
||||
SubtitleProfile(format: "subviewer1", method: .external),
|
||||
SubtitleProfile(format: "sami", method: .external), // SMI
|
||||
SubtitleProfile(format: "realtext", method: .external),
|
||||
SubtitleProfile(format: "pjs", method: .external), // Phoenix Subtitle
|
||||
SubtitleProfile(format: "mpl2", method: .external),
|
||||
SubtitleProfile(format: "jacosub", method: .external),
|
||||
]
|
||||
}
|
||||
buildProfileSwiftfin()
|
||||
|
||||
case .native:
|
||||
func buildProfileNative() {
|
||||
// Build direct play profiles
|
||||
directPlayProfiles = [
|
||||
// Apple limitation: no mp3 in mp4; avi only supports mjpeg with pcm
|
||||
// Right now, mp4 restrictions can't be enforced because mp4, m4v, mov, 3gp,3g2 treated the same
|
||||
DirectPlayProfile(
|
||||
audioCodec: "flac,alac,aac,eac3,ac3,opus",
|
||||
container: "mp4",
|
||||
type: .video,
|
||||
videoCodec: "hevc,h264,mpeg4"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "alac,aac,ac3",
|
||||
container: "m4v",
|
||||
type: .video,
|
||||
videoCodec: "h264,mpeg4"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "alac,aac,eac3,ac3,mp3,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le",
|
||||
container: "mov",
|
||||
type: .video,
|
||||
videoCodec: "hevc,h264,mpeg4,mjpeg"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "aac,eac3,ac3,mp3",
|
||||
container: "mpegts",
|
||||
type: .video,
|
||||
videoCodec: "h264"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "aac,amr_nb",
|
||||
container: "3gp,3g2",
|
||||
type: .video,
|
||||
videoCodec: "h264,mpeg4"
|
||||
),
|
||||
DirectPlayProfile(
|
||||
audioCodec: "pcm_s16le,pcm_mulaw",
|
||||
container: "avi",
|
||||
type: .video,
|
||||
videoCodec: "mjpeg"
|
||||
),
|
||||
]
|
||||
|
||||
// Build transcoding profiles
|
||||
transcodingProfiles = [
|
||||
TranscodingProfile(
|
||||
audioCodec: "flac,alac,aac,eac3,ac3,opus",
|
||||
isBreakOnNonKeyFrames: true,
|
||||
container: "mp4",
|
||||
context: .streaming,
|
||||
maxAudioChannels: "8",
|
||||
minSegments: 2,
|
||||
protocol: "hls",
|
||||
type: .video,
|
||||
videoCodec: "hevc,h264,mpeg4"
|
||||
),
|
||||
]
|
||||
|
||||
// Create subtitle profiles
|
||||
subtitleProfiles = [
|
||||
// FFmpeg can only convert bitmap to bitmap and text to text; burn in bitmap subs
|
||||
SubtitleProfile(format: "pgssub", method: .encode),
|
||||
SubtitleProfile(format: "dvdsub", method: .encode),
|
||||
SubtitleProfile(format: "dvbsub", method: .encode),
|
||||
SubtitleProfile(format: "xsub", method: .encode),
|
||||
// According to Apple HLS authoring specs, WebVTT must be in a text file delivered via HLS
|
||||
SubtitleProfile(format: "vtt", method: .hls), // webvtt
|
||||
// Apple HLS authoring spec has closed captions in video segments and TTML in fmp4
|
||||
SubtitleProfile(format: "ttml", method: .embed),
|
||||
SubtitleProfile(format: "cc_dec", method: .embed),
|
||||
]
|
||||
}
|
||||
buildProfileNative()
|
||||
}
|
||||
|
||||
// For now, assume native and VLCKit support same codec conditions:
|
||||
let h264CodecConditions: [ProfileCondition] = [
|
||||
ProfileCondition(
|
||||
condition: .notEquals,
|
||||
isRequired: false,
|
||||
property: .isAnamorphic,
|
||||
value: "true"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .equalsAny,
|
||||
isRequired: false,
|
||||
property: .videoProfile,
|
||||
value: "high|main|baseline|constrained baseline"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .lessThanEqual,
|
||||
isRequired: false,
|
||||
property: .videoLevel,
|
||||
value: "80"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .notEquals,
|
||||
isRequired: false,
|
||||
property: .isInterlaced,
|
||||
value: "true"
|
||||
),
|
||||
]
|
||||
|
||||
codecProfiles.append(CodecProfile(applyConditions: h264CodecConditions, codec: "h264", type: .video))
|
||||
|
||||
let hevcCodecConditions: [ProfileCondition] = [
|
||||
ProfileCondition(
|
||||
condition: .notEquals,
|
||||
isRequired: false,
|
||||
property: .isAnamorphic,
|
||||
value: "true"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .equalsAny,
|
||||
isRequired: false,
|
||||
property: .videoProfile,
|
||||
value: "high|main|main 10"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .lessThanEqual,
|
||||
isRequired: false,
|
||||
property: .videoLevel,
|
||||
value: "175"
|
||||
),
|
||||
ProfileCondition(
|
||||
condition: .notEquals,
|
||||
isRequired: false,
|
||||
property: .isInterlaced,
|
||||
value: "true"
|
||||
),
|
||||
]
|
||||
|
||||
codecProfiles.append(CodecProfile(applyConditions: hevcCodecConditions, codec: "hevc", type: .video))
|
||||
|
||||
let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", mimeType: "video/mp4", type: .video)]
|
||||
|
||||
return .init(
|
||||
codecProfiles: codecProfiles,
|
||||
containerProfiles: [],
|
||||
directPlayProfiles: directPlayProfiles,
|
||||
maxStaticBitrate: maxStaticBitrate,
|
||||
maxStreamingBitrate: maxStreamingBitrate,
|
||||
musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate,
|
||||
responseProfiles: responseProfiles,
|
||||
subtitleProfiles: subtitleProfiles,
|
||||
transcodingProfiles: transcodingProfiles
|
||||
)
|
||||
}
|
||||
}
|
@ -6,14 +6,12 @@
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
class StaticLibraryViewModel: PagingLibraryViewModel {
|
||||
protocol Eventful {
|
||||
|
||||
init(items: [BaseItemDto]) {
|
||||
super.init()
|
||||
associatedtype Event
|
||||
|
||||
self.items.elements = items
|
||||
}
|
||||
var events: AnyPublisher<Event, Never> { get }
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
enum FilterDrawerButtonSelection: String, CaseIterable, Defaults.Serializable, Displayable, Identifiable {
|
||||
|
||||
case filters
|
||||
case genres
|
||||
case order
|
||||
case sort
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .filters:
|
||||
return L10n.filters
|
||||
case .genres:
|
||||
return L10n.genres
|
||||
case .order:
|
||||
return L10n.order
|
||||
case .sort:
|
||||
return L10n.sort
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var itemFilter: WritableKeyPath<ItemFilters, [ItemFilters.Filter]> {
|
||||
switch self {
|
||||
case .filters:
|
||||
return \.filters
|
||||
case .genres:
|
||||
return \.genres
|
||||
case .order:
|
||||
return \.sortOrder
|
||||
case .sort:
|
||||
return \.sortBy
|
||||
}
|
||||
}
|
||||
|
||||
var selectorType: SelectorType {
|
||||
switch self {
|
||||
case .filters, .genres:
|
||||
return .multi
|
||||
case .order, .sort:
|
||||
return .single
|
||||
}
|
||||
}
|
||||
|
||||
var itemFilterDefault: [ItemFilters.Filter] {
|
||||
switch self {
|
||||
case .filters:
|
||||
return []
|
||||
case .genres:
|
||||
return []
|
||||
case .order:
|
||||
return [APISortOrder.ascending.filter]
|
||||
case .sort:
|
||||
return [SortBy.name.filter]
|
||||
}
|
||||
}
|
||||
|
||||
func isItemsFilterActive(activeFilters: ItemFilters) -> Bool {
|
||||
switch self {
|
||||
case .filters:
|
||||
return activeFilters.filters != self.itemFilterDefault
|
||||
case .genres:
|
||||
return activeFilters.genres != self.itemFilterDefault
|
||||
case .order:
|
||||
return activeFilters.sortOrder != self.itemFilterDefault
|
||||
case .sort:
|
||||
return activeFilters.sortBy != self.itemFilterDefault
|
||||
}
|
||||
}
|
||||
|
||||
static var defaultFilterDrawerButtons: [FilterDrawerButtonSelection] {
|
||||
[
|
||||
.filters,
|
||||
.genres,
|
||||
.order,
|
||||
.sort,
|
||||
]
|
||||
}
|
||||
}
|
@ -9,6 +9,8 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
// TODO: split out into separate files under folder `GestureAction`
|
||||
|
||||
// Optional values aren't yet supported in Defaults
|
||||
// https://github.com/sindresorhus/Defaults/issues/54
|
||||
|
||||
|
@ -6,15 +6,18 @@
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
enum HTTPScheme: String, CaseIterable, Displayable, Defaults.Serializable {
|
||||
struct ImageSource: Hashable {
|
||||
|
||||
case http
|
||||
case https
|
||||
let url: URL?
|
||||
let blurHash: String?
|
||||
|
||||
var displayTitle: String {
|
||||
rawValue
|
||||
init(
|
||||
url: URL? = nil,
|
||||
blurHash: String? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.blurHash = blurHash
|
||||
}
|
||||
}
|
29
Shared/Objects/ItemFilter/AnyItemFilter.swift
Normal file
29
Shared/Objects/ItemFilter/AnyItemFilter.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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A type-erased instance of an item filter.
|
||||
struct AnyItemFilter: Displayable, Hashable, ItemFilter {
|
||||
|
||||
let displayTitle: String
|
||||
let value: String
|
||||
|
||||
init(
|
||||
displayTitle: String,
|
||||
value: String
|
||||
) {
|
||||
self.displayTitle = displayTitle
|
||||
self.value = value
|
||||
}
|
||||
|
||||
init(from anyFilter: AnyItemFilter) {
|
||||
self.displayTitle = anyFilter.displayTitle
|
||||
self.value = anyFilter.value
|
||||
}
|
||||
}
|
44
Shared/Objects/ItemFilter/ItemFilter.swift
Normal file
44
Shared/Objects/ItemFilter/ItemFilter.swift
Normal file
@ -0,0 +1,44 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
protocol ItemFilter: Displayable {
|
||||
|
||||
var value: String { get }
|
||||
|
||||
// TODO: Should this be optional if the concrete type
|
||||
// can't be constructed?
|
||||
init(from anyFilter: AnyItemFilter)
|
||||
}
|
||||
|
||||
extension ItemFilter {
|
||||
|
||||
var asAnyItemFilter: AnyItemFilter {
|
||||
.init(displayTitle: displayTitle, value: value)
|
||||
}
|
||||
}
|
||||
|
||||
extension ItemFilter where Self: RawRepresentable<String> {
|
||||
|
||||
var value: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
init(from anyFilter: AnyItemFilter) {
|
||||
self.init(rawValue: anyFilter.value)!
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element: ItemFilter {
|
||||
|
||||
var asAnyItemFilter: [AnyItemFilter] {
|
||||
map(\.asAnyItemFilter)
|
||||
}
|
||||
}
|
56
Shared/Objects/ItemFilter/ItemFilterCollection.swift
Normal file
56
Shared/Objects/ItemFilter/ItemFilterCollection.swift
Normal 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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
/// A structure representing a collection of item filters
|
||||
struct ItemFilterCollection: Codable, Defaults.Serializable, Hashable {
|
||||
|
||||
var genres: [ItemGenre] = []
|
||||
var sortBy: [ItemSortBy] = [ItemSortBy.name]
|
||||
var sortOrder: [ItemSortOrder] = [ItemSortOrder.ascending]
|
||||
var tags: [ItemTag] = []
|
||||
var traits: [ItemTrait] = []
|
||||
var years: [ItemYear] = []
|
||||
|
||||
/// The default collection of filters
|
||||
static let `default`: ItemFilterCollection = .init()
|
||||
|
||||
static let favorites: ItemFilterCollection = .init(
|
||||
traits: [ItemTrait.isFavorite]
|
||||
)
|
||||
static let recent: ItemFilterCollection = .init(
|
||||
sortBy: [ItemSortBy.dateAdded],
|
||||
sortOrder: [ItemSortOrder.descending]
|
||||
)
|
||||
|
||||
/// A collection that has all statically available values
|
||||
static let all: ItemFilterCollection = .init(
|
||||
sortBy: ItemSortBy.allCases,
|
||||
sortOrder: ItemSortOrder.allCases,
|
||||
traits: ItemTrait.supportedCases
|
||||
)
|
||||
|
||||
var hasFilters: Bool {
|
||||
self != Self.default
|
||||
}
|
||||
|
||||
var activeFilterCount: Int {
|
||||
var count = 0
|
||||
|
||||
for filter in ItemFilterType.allCases {
|
||||
if self[keyPath: filter.collectionAnyKeyPath] != Self.default[keyPath: filter.collectionAnyKeyPath] {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
}
|
67
Shared/Objects/ItemFilter/ItemFilterType.swift
Normal file
67
Shared/Objects/ItemFilter/ItemFilterType.swift
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
enum ItemFilterType: String, CaseIterable, Defaults.Serializable {
|
||||
|
||||
case genres
|
||||
case sortBy
|
||||
case sortOrder
|
||||
case tags
|
||||
case traits
|
||||
case years
|
||||
|
||||
// TODO: rename to something indicating plurality instead of concrete type?
|
||||
var selectorType: SelectorType {
|
||||
switch self {
|
||||
case .genres, .tags, .traits, .years:
|
||||
return .multi
|
||||
case .sortBy, .sortOrder:
|
||||
return .single
|
||||
}
|
||||
}
|
||||
|
||||
var collectionAnyKeyPath: KeyPath<ItemFilterCollection, [AnyItemFilter]> {
|
||||
switch self {
|
||||
case .genres:
|
||||
\ItemFilterCollection.genres.asAnyItemFilter
|
||||
case .sortBy:
|
||||
\ItemFilterCollection.sortBy.asAnyItemFilter
|
||||
case .sortOrder:
|
||||
\ItemFilterCollection.sortOrder.asAnyItemFilter
|
||||
case .tags:
|
||||
\ItemFilterCollection.tags.asAnyItemFilter
|
||||
case .traits:
|
||||
\ItemFilterCollection.traits.asAnyItemFilter
|
||||
case .years:
|
||||
\ItemFilterCollection.years.asAnyItemFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ItemFilterType: Displayable {
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .genres:
|
||||
L10n.genres
|
||||
case .sortBy:
|
||||
L10n.sort
|
||||
case .sortOrder:
|
||||
L10n.order
|
||||
case .tags:
|
||||
L10n.tags
|
||||
case .traits:
|
||||
L10n.filters
|
||||
case .years:
|
||||
"Years"
|
||||
}
|
||||
}
|
||||
}
|
26
Shared/Objects/ItemFilter/ItemGenre.swift
Normal file
26
Shared/Objects/ItemFilter/ItemGenre.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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ItemGenre: Codable, ExpressibleByStringLiteral, Hashable, ItemFilter {
|
||||
|
||||
let value: String
|
||||
|
||||
var displayTitle: String {
|
||||
value
|
||||
}
|
||||
|
||||
init(stringLiteral value: String) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
init(from anyFilter: AnyItemFilter) {
|
||||
self.value = anyFilter.value
|
||||
}
|
||||
}
|
@ -9,9 +9,9 @@
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
// TODO: Move to jellyfin-api-swift
|
||||
// TODO: Remove when JellyfinAPI generates 10.9.0 schema
|
||||
|
||||
enum SortBy: String, CaseIterable, Displayable {
|
||||
enum ItemSortBy: String, CaseIterable, Displayable, Codable {
|
||||
|
||||
case premiereDate = "PremiereDate"
|
||||
case name = "SortName"
|
||||
@ -31,8 +31,15 @@ enum SortBy: String, CaseIterable, Displayable {
|
||||
return "Random"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var filter: ItemFilters.Filter {
|
||||
.init(displayTitle: displayTitle, id: rawValue, filterName: rawValue)
|
||||
extension ItemSortBy: ItemFilter {
|
||||
|
||||
var value: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
init(from anyFilter: AnyItemFilter) {
|
||||
self.init(rawValue: anyFilter.value)!
|
||||
}
|
||||
}
|
26
Shared/Objects/ItemFilter/ItemTag.swift
Normal file
26
Shared/Objects/ItemFilter/ItemTag.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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ItemTag: Codable, ExpressibleByStringLiteral, Hashable, ItemFilter {
|
||||
|
||||
let value: String
|
||||
|
||||
var displayTitle: String {
|
||||
value
|
||||
}
|
||||
|
||||
init(stringLiteral value: String) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
init(from anyFilter: AnyItemFilter) {
|
||||
self.value = anyFilter.value
|
||||
}
|
||||
}
|
30
Shared/Objects/ItemFilter/ItemYear.swift
Normal file
30
Shared/Objects/ItemFilter/ItemYear.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ItemYear: Codable, ExpressibleByIntegerLiteral, Hashable, ItemFilter {
|
||||
|
||||
let value: String
|
||||
|
||||
var displayTitle: String {
|
||||
value
|
||||
}
|
||||
|
||||
var intValue: Int {
|
||||
Int(value)!
|
||||
}
|
||||
|
||||
init(integerLiteral value: IntegerLiteralType) {
|
||||
self.value = "\(value)"
|
||||
}
|
||||
|
||||
init(from anyFilter: AnyItemFilter) {
|
||||
self.value = anyFilter.value
|
||||
}
|
||||
}
|
@ -1,39 +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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Defaults
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
struct ItemFilters: Codable, Defaults.Serializable, Hashable {
|
||||
|
||||
var genres: [Filter] = []
|
||||
var filters: [Filter] = []
|
||||
var sortOrder: [Filter] = [APISortOrder.ascending.filter]
|
||||
var sortBy: [Filter] = [SortBy.name.filter]
|
||||
|
||||
static let favorites: ItemFilters = .init(filters: [ItemFilter.isFavorite.filter])
|
||||
static let recent: ItemFilters = .init(sortOrder: [APISortOrder.descending.filter], sortBy: [SortBy.dateAdded.filter])
|
||||
static let all: ItemFilters = .init(
|
||||
filters: ItemFilter.supportedCases.map(\.filter),
|
||||
sortOrder: APISortOrder.allCases.map(\.filter),
|
||||
sortBy: SortBy.allCases.map(\.filter)
|
||||
)
|
||||
|
||||
var hasFilters: Bool {
|
||||
self != .init()
|
||||
}
|
||||
|
||||
// Type-erased object for use with WritableKeyPath
|
||||
struct Filter: Codable, Defaults.Serializable, Displayable, Hashable, Identifiable {
|
||||
var displayTitle: String
|
||||
var id: String?
|
||||
var filterName: String
|
||||
}
|
||||
}
|
22
Shared/Objects/LibraryParent/LibraryParent.swift
Normal file
22
Shared/Objects/LibraryParent/LibraryParent.swift
Normal file
@ -0,0 +1,22 @@
|
||||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
protocol LibraryParent: Displayable, Identifiable<String?> {
|
||||
|
||||
// Only called `libraryType` because `BaseItemPerson` has
|
||||
// a different `type` property. However, people should have
|
||||
// different views so this can be renamed when they do, or
|
||||
// this protocol to be removed entirely and replace just with
|
||||
// a concrete `BaseItemDto`
|
||||
//
|
||||
// edit: studios also implement `LibraryParent` - reconsider above comment
|
||||
var libraryType: BaseItemKind? { get }
|
||||
}
|
@ -7,15 +7,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
protocol LibraryParent: Displayable {
|
||||
var id: String? { get }
|
||||
}
|
||||
/// A basic structure conforming to `LibraryParent` that is meant to only define its `displayTitle`
|
||||
struct TitledLibraryParent: LibraryParent {
|
||||
|
||||
// TODO: Remove so multiple people/studios can be used
|
||||
enum LibraryParentType {
|
||||
case library
|
||||
case folders
|
||||
case person
|
||||
case studio
|
||||
let displayTitle: String
|
||||
let id: String? = nil
|
||||
let libraryType: BaseItemKind? = nil
|
||||
}
|
@ -8,19 +8,20 @@
|
||||
|
||||
import Defaults
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum LibraryViewType: String, CaseIterable, Displayable, Defaults.Serializable {
|
||||
|
||||
case grid
|
||||
case list
|
||||
|
||||
// TODO: localize after organization
|
||||
// TODO: localize
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .grid:
|
||||
return "Grid"
|
||||
"Grid"
|
||||
case .list:
|
||||
return "List"
|
||||
"List"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user