Refactor PosterButton and libraries, good UICollectionViews, proper orientation handling, and more (#905)

This commit is contained in:
Ethan Pippin 2024-03-11 08:09:30 -06:00 committed by GitHub
parent 11cc5f56ac
commit a645444f25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
285 changed files with 6679 additions and 5829 deletions

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "swizzleswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/MarioIannotta/SwizzleSwift",
"state" : {
"branch" : "master",
"revision" : "e2d31c646182bf94a496b173c6ee5ad191230e9a"
}
}
],
"version" : 2
}

View 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")]
),
]
)

View File

@ -0,0 +1 @@
# PreferencesView

View File

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

View File

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

View File

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

View File

@ -0,0 +1,43 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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

View File

@ -0,0 +1,26 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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) {}
}

View File

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

View File

@ -0,0 +1,74 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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
}
}

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -1,65 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@
import Foundation
import JellyfinAPI
// TODO: remove
struct ErrorMessage: Hashable, Identifiable {
let code: Int?

View File

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,63 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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 }
}
}

View File

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

View File

@ -1,35 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,43 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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) }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1,29 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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
}
}
}

View File

@ -0,0 +1,39 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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()
}
}
}

View File

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

View File

@ -0,0 +1,27 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,29 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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
}
}

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

View File

@ -0,0 +1,56 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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
}
}

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

View File

@ -0,0 +1,26 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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
}
}

View File

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

View File

@ -0,0 +1,26 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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
}
}

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

View File

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

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

View File

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

View File

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