Item Views to `Stateful` (#997)
This commit is contained in:
parent
4ed9e52d1e
commit
fd1a87cb02
|
@ -10,6 +10,7 @@ import Defaults
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// TODO: only allow `view` selection when truncated?
|
// TODO: only allow `view` selection when truncated?
|
||||||
|
// TODO: fix when also using `lineLimit(reserveSpace > 1)`
|
||||||
|
|
||||||
struct TruncatedText: View {
|
struct TruncatedText: View {
|
||||||
|
|
||||||
|
|
|
@ -13,4 +13,12 @@ extension CGSize {
|
||||||
static func Square(length: CGFloat) -> CGSize {
|
static func Square(length: CGFloat) -> CGSize {
|
||||||
CGSize(width: length, height: length)
|
CGSize(width: length, height: length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isLandscape: Bool {
|
||||||
|
width >= height
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPortrait: Bool {
|
||||||
|
height >= width
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -259,4 +259,21 @@ extension BaseItemDto {
|
||||||
var alternateTitle: String? {
|
var alternateTitle: String? {
|
||||||
originalTitle != displayTitle ? originalTitle : nil
|
originalTitle != displayTitle ? originalTitle : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var playButtonLabel: String {
|
||||||
|
|
||||||
|
if isUnaired {
|
||||||
|
return L10n.unaired
|
||||||
|
}
|
||||||
|
|
||||||
|
if isMissing {
|
||||||
|
return L10n.missing
|
||||||
|
}
|
||||||
|
|
||||||
|
if let progressLabel {
|
||||||
|
return progressLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
return L10n.play
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import Foundation
|
||||||
///
|
///
|
||||||
/// - Important: Only really use for debugging. For practical errors,
|
/// - Important: Only really use for debugging. For practical errors,
|
||||||
/// statically define errors for each domain/context.
|
/// statically define errors for each domain/context.
|
||||||
struct JellyfinAPIError: LocalizedError, Equatable {
|
struct JellyfinAPIError: LocalizedError, Hashable {
|
||||||
|
|
||||||
private let message: String
|
private let message: String
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Algorithms
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@ -19,6 +20,8 @@ extension String: Displayable {
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
|
|
||||||
|
static let alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
|
||||||
static func + (lhs: String, rhs: Character) -> String {
|
static func + (lhs: String, rhs: Character) -> String {
|
||||||
lhs.appending(rhs)
|
lhs.appending(rhs)
|
||||||
}
|
}
|
||||||
|
@ -84,6 +87,16 @@ extension String {
|
||||||
(split(separator: "/").last?.description ?? self)
|
(split(separator: "/").last?.description ?? self)
|
||||||
.replacingOccurrences(of: ".swift", with: "")
|
.replacingOccurrences(of: ".swift", with: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func random(count: Int) -> String {
|
||||||
|
let characters = Self.alphanumeric.randomSample(count: count)
|
||||||
|
return String(characters)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func random(count range: Range<Int>) -> String {
|
||||||
|
let characters = Self.alphanumeric.randomSample(count: Int.random(in: range))
|
||||||
|
return String(characters)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CharacterSet {
|
extension CharacterSet {
|
||||||
|
|
|
@ -11,11 +11,14 @@ import SwiftUI
|
||||||
struct BackgroundParallaxHeaderModifier<Header: View>: ViewModifier {
|
struct BackgroundParallaxHeaderModifier<Header: View>: ViewModifier {
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
var scrollViewOffset: CGFloat
|
private var scrollViewOffset: CGFloat
|
||||||
|
|
||||||
let height: CGFloat
|
@State
|
||||||
let multiplier: CGFloat
|
private var contentSize: CGSize = .zero
|
||||||
let header: () -> Header
|
|
||||||
|
private let height: CGFloat
|
||||||
|
private let multiplier: CGFloat
|
||||||
|
private let header: () -> Header
|
||||||
|
|
||||||
init(
|
init(
|
||||||
_ scrollViewOffset: Binding<CGFloat>,
|
_ scrollViewOffset: Binding<CGFloat>,
|
||||||
|
@ -30,15 +33,18 @@ struct BackgroundParallaxHeaderModifier<Header: View>: ViewModifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content.background(alignment: .top) {
|
content
|
||||||
header()
|
.size($contentSize)
|
||||||
.offset(y: scrollViewOffset > 0 ? -scrollViewOffset * multiplier : 0)
|
.background(alignment: .top) {
|
||||||
.scaleEffect(scrollViewOffset < 0 ? (height - scrollViewOffset) / height : 1, anchor: .top)
|
header()
|
||||||
.mask(alignment: .top) {
|
.offset(y: scrollViewOffset > 0 ? -scrollViewOffset * multiplier : 0)
|
||||||
Color.black
|
.scaleEffect(scrollViewOffset < 0 ? (height - scrollViewOffset) / height : 1, anchor: .top)
|
||||||
.frame(height: max(0, height - scrollViewOffset))
|
.frame(width: contentSize.width)
|
||||||
}
|
.mask(alignment: .top) {
|
||||||
.ignoresSafeArea()
|
Color.black
|
||||||
}
|
.frame(height: max(0, height - scrollViewOffset))
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,15 +91,15 @@ extension View {
|
||||||
|
|
||||||
/// Applies the aspect ratio and corner radius for the given `PosterType`
|
/// Applies the aspect ratio and corner radius for the given `PosterType`
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func posterStyle(_ type: PosterType) -> some View {
|
func posterStyle(_ type: PosterType, contentMode: ContentMode = .fill) -> some View {
|
||||||
switch type {
|
switch type {
|
||||||
case .portrait:
|
case .portrait:
|
||||||
aspectRatio(2 / 3, contentMode: .fill)
|
aspectRatio(2 / 3, contentMode: contentMode)
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.cornerRadius(ratio: 0.0375, of: \.width)
|
.cornerRadius(ratio: 0.0375, of: \.width)
|
||||||
#endif
|
#endif
|
||||||
case .landscape:
|
case .landscape:
|
||||||
aspectRatio(1.77, contentMode: .fill)
|
aspectRatio(1.77, contentMode: contentMode)
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.cornerRadius(ratio: 1 / 30, of: \.width)
|
.cornerRadius(ratio: 1 / 30, of: \.width)
|
||||||
#endif
|
#endif
|
||||||
|
@ -152,6 +152,7 @@ extension View {
|
||||||
.onPreferenceChange(FramePreferenceKey.self, perform: onChange)
|
.onPreferenceChange(FramePreferenceKey.self, perform: onChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: probably rename since this doesn't set the frame but tracks it
|
||||||
func frame(_ binding: Binding<CGRect>) -> some View {
|
func frame(_ binding: Binding<CGRect>) -> some View {
|
||||||
onFrameChanged { newFrame in
|
onFrameChanged { newFrame in
|
||||||
binding.wrappedValue = newFrame
|
binding.wrappedValue = newFrame
|
||||||
|
@ -173,6 +174,7 @@ extension View {
|
||||||
.onPreferenceChange(LocationPreferenceKey.self, perform: onChange)
|
.onPreferenceChange(LocationPreferenceKey.self, perform: onChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: probably rename since this doesn't set the location but tracks it
|
||||||
func location(_ binding: Binding<CGPoint>) -> some View {
|
func location(_ binding: Binding<CGPoint>) -> some View {
|
||||||
onLocationChanged { newLocation in
|
onLocationChanged { newLocation in
|
||||||
binding.wrappedValue = newLocation
|
binding.wrappedValue = newLocation
|
||||||
|
@ -191,6 +193,7 @@ extension View {
|
||||||
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
|
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: probably rename since this doesn't set the size but tracks it
|
||||||
func size(_ binding: Binding<CGSize>) -> some View {
|
func size(_ binding: Binding<CGSize>) -> some View {
|
||||||
onSizeChanged { newSize in
|
onSizeChanged { newSize in
|
||||||
binding.wrappedValue = newSize
|
binding.wrappedValue = newSize
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
protocol LibraryParent: Displayable, Identifiable<String?> {
|
protocol LibraryParent: Displayable, Hashable, Identifiable<String?> {
|
||||||
|
|
||||||
// Only called `libraryType` because `BaseItemPerson` has
|
// Only called `libraryType` because `BaseItemPerson` has
|
||||||
// a different `type` property. However, people should have
|
// a different `type` property. However, people should have
|
||||||
|
|
|
@ -7,24 +7,38 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OrderedCollections
|
||||||
|
|
||||||
// TODO: documentation
|
// TODO: documentation
|
||||||
|
// TODO: find a better way to handle backgroundStates on action/state transitions
|
||||||
|
// so that conformers don't have to manually insert/remove them
|
||||||
|
// TODO: better/official way for subclasses of conformers to perform actions during
|
||||||
|
// parent class actions
|
||||||
|
// TODO: official way for a cleaner `respond` method so it doesn't have all Task
|
||||||
|
// construction and get bloated
|
||||||
|
|
||||||
protocol Stateful: AnyObject {
|
protocol Stateful: AnyObject {
|
||||||
|
|
||||||
associatedtype Action
|
associatedtype Action: Equatable
|
||||||
associatedtype State: Equatable
|
associatedtype BackgroundState: Hashable = Never
|
||||||
|
associatedtype State: Hashable
|
||||||
|
|
||||||
|
/// Background states that the conformer can be in.
|
||||||
|
/// Usually used to indicate background events that shouldn't
|
||||||
|
/// set the conformer to a primary state.
|
||||||
|
var backgroundStates: OrderedSet<BackgroundState> { get set }
|
||||||
|
|
||||||
|
var lastAction: Action? { get set }
|
||||||
var state: State { get set }
|
var state: State { get set }
|
||||||
|
|
||||||
|
/// Respond to a sent action and return the new state
|
||||||
|
@MainActor
|
||||||
|
func respond(to action: Action) -> State
|
||||||
|
|
||||||
/// Send an action to the `Stateful` object, which will
|
/// Send an action to the `Stateful` object, which will
|
||||||
/// `respond` to the action and set the new state.
|
/// `respond` to the action and set the new state.
|
||||||
@MainActor
|
@MainActor
|
||||||
func send(_ action: Action)
|
func send(_ action: Action)
|
||||||
|
|
||||||
/// Respond to a sent action and return the new state
|
|
||||||
@MainActor
|
|
||||||
func respond(to action: Action) -> State
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Stateful {
|
extension Stateful {
|
||||||
|
@ -32,5 +46,17 @@ extension Stateful {
|
||||||
@MainActor
|
@MainActor
|
||||||
func send(_ action: Action) {
|
func send(_ action: Action) {
|
||||||
state = respond(to: action)
|
state = respond(to: action)
|
||||||
|
lastAction = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Stateful where BackgroundState == Never {
|
||||||
|
|
||||||
|
var backgroundStates: OrderedSet<Never> {
|
||||||
|
get {
|
||||||
|
assertionFailure("Attempted to access `backgroundStates` when there are none")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
set { assertionFailure("Attempted to set `backgroundStates` when there are none") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,22 +14,26 @@ class SwiftfinNotification {
|
||||||
@Injected(Notifications.service)
|
@Injected(Notifications.service)
|
||||||
private var notificationService
|
private var notificationService
|
||||||
|
|
||||||
private let notificationName: Notification.Name
|
private let name: Notification.Name
|
||||||
|
|
||||||
fileprivate init(_ notificationName: Notification.Name) {
|
fileprivate init(_ notificationName: Notification.Name) {
|
||||||
self.notificationName = notificationName
|
self.name = notificationName
|
||||||
}
|
}
|
||||||
|
|
||||||
func post(object: Any? = nil) {
|
func post(object: Any? = nil) {
|
||||||
notificationService.post(name: notificationName, object: object)
|
notificationService.post(name: name, object: object)
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribe(_ observer: Any, selector: Selector) {
|
func subscribe(_ observer: Any, selector: Selector) {
|
||||||
notificationService.addObserver(observer, selector: selector, name: notificationName, object: nil)
|
notificationService.addObserver(observer, selector: selector, name: name, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe(_ observer: Any) {
|
func unsubscribe(_ observer: Any) {
|
||||||
notificationService.removeObserver(self, name: notificationName, object: nil)
|
notificationService.removeObserver(self, name: name, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var publisher: NotificationCenter.Publisher {
|
||||||
|
notificationService.publisher(for: name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,7 +41,16 @@ enum Notifications {
|
||||||
|
|
||||||
static let service = Factory(scope: .singleton) { NotificationCenter() }
|
static let service = Factory(scope: .singleton) { NotificationCenter() }
|
||||||
|
|
||||||
final class Key {
|
struct Key: Hashable {
|
||||||
|
|
||||||
|
static func == (lhs: Notifications.Key, rhs: Notifications.Key) -> Bool {
|
||||||
|
lhs.key == rhs.key
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(key)
|
||||||
|
}
|
||||||
|
|
||||||
typealias NotificationKey = Notifications.Key
|
typealias NotificationKey = Notifications.Key
|
||||||
|
|
||||||
let key: String
|
let key: String
|
||||||
|
@ -63,4 +76,6 @@ extension Notifications.Key {
|
||||||
static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI")
|
static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI")
|
||||||
static let didSendStopReport = NotificationKey("didSendStopReport")
|
static let didSendStopReport = NotificationKey("didSendStopReport")
|
||||||
static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh")
|
static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh")
|
||||||
|
|
||||||
|
static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange")
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
import Combine
|
import Combine
|
||||||
import CoreStore
|
import CoreStore
|
||||||
import Factory
|
import Factory
|
||||||
|
import Get
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import OrderedCollections
|
import OrderedCollections
|
||||||
|
|
||||||
|
@ -16,14 +17,22 @@ final class HomeViewModel: ViewModel, Stateful {
|
||||||
|
|
||||||
// MARK: Action
|
// MARK: Action
|
||||||
|
|
||||||
enum Action {
|
enum Action: Equatable {
|
||||||
|
case backgroundRefresh
|
||||||
case error(JellyfinAPIError)
|
case error(JellyfinAPIError)
|
||||||
|
case setIsPlayed(Bool, BaseItemDto)
|
||||||
|
case refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: BackgroundState
|
||||||
|
|
||||||
|
enum BackgroundState: Hashable {
|
||||||
case refresh
|
case refresh
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: State
|
// MARK: State
|
||||||
|
|
||||||
enum State: Equatable {
|
enum State: Hashable {
|
||||||
case content
|
case content
|
||||||
case error(JellyfinAPIError)
|
case error(JellyfinAPIError)
|
||||||
case initial
|
case initial
|
||||||
|
@ -31,26 +40,93 @@ final class HomeViewModel: ViewModel, Stateful {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var libraries: [LatestInLibraryViewModel] = []
|
private(set) var libraries: [LatestInLibraryViewModel] = []
|
||||||
@Published
|
@Published
|
||||||
var resumeItems: OrderedSet<BaseItemDto> = []
|
var resumeItems: OrderedSet<BaseItemDto> = []
|
||||||
|
|
||||||
|
@Published
|
||||||
|
var backgroundStates: OrderedSet<BackgroundState> = []
|
||||||
|
@Published
|
||||||
|
var lastAction: Action? = nil
|
||||||
@Published
|
@Published
|
||||||
var state: State = .initial
|
var state: State = .initial
|
||||||
|
|
||||||
private(set) var nextUpViewModel: NextUpLibraryViewModel = .init()
|
// TODO: replace with views checking what notifications were
|
||||||
private(set) var recentlyAddedViewModel: RecentlyAddedLibraryViewModel = .init()
|
// posted since last disappear
|
||||||
|
@Published
|
||||||
|
var notificationsReceived: Set<Notifications.Key> = []
|
||||||
|
|
||||||
|
private var backgroundRefreshTask: AnyCancellable?
|
||||||
private var refreshTask: AnyCancellable?
|
private var refreshTask: AnyCancellable?
|
||||||
|
|
||||||
|
var nextUpViewModel: NextUpLibraryViewModel = .init()
|
||||||
|
var recentlyAddedViewModel: RecentlyAddedLibraryViewModel = .init()
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
Notifications[.itemMetadataDidChange].publisher
|
||||||
|
.sink { _ in
|
||||||
|
// Necessary because when this notification is posted, even with asyncAfter,
|
||||||
|
// the view will cause layout issues since it will redraw while in landscape.
|
||||||
|
// TODO: look for better solution
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.notificationsReceived.insert(Notifications.Key.itemMetadataDidChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
func respond(to action: Action) -> State {
|
func respond(to action: Action) -> State {
|
||||||
switch action {
|
switch action {
|
||||||
|
case .backgroundRefresh:
|
||||||
|
|
||||||
|
backgroundRefreshTask?.cancel()
|
||||||
|
backgroundStates.append(.refresh)
|
||||||
|
|
||||||
|
backgroundRefreshTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
|
||||||
|
nextUpViewModel.send(.refresh)
|
||||||
|
recentlyAddedViewModel.send(.refresh)
|
||||||
|
|
||||||
|
let resumeItems = try await getResumeItems()
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.resumeItems.elements = resumeItems
|
||||||
|
self.backgroundStates.remove(.refresh)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.backgroundStates.remove(.refresh)
|
||||||
|
self.send(.error(.init(error.localizedDescription)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.asAnyCancellable()
|
||||||
|
|
||||||
|
return state
|
||||||
case let .error(error):
|
case let .error(error):
|
||||||
return .error(error)
|
return .error(error)
|
||||||
case .refresh:
|
case let .setIsPlayed(isPlayed, item): ()
|
||||||
cancellables.removeAll()
|
Task {
|
||||||
|
try await setIsPlayed(isPlayed, for: item)
|
||||||
|
|
||||||
Task { [weak self] in
|
self.send(.backgroundRefresh)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
return state
|
||||||
|
case .refresh:
|
||||||
|
backgroundRefreshTask?.cancel()
|
||||||
|
refreshTask?.cancel()
|
||||||
|
|
||||||
|
refreshTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
|
|
||||||
|
@ -69,7 +145,7 @@ final class HomeViewModel: ViewModel, Stateful {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.asAnyCancellable()
|
||||||
|
|
||||||
return .refreshing
|
return .refreshing
|
||||||
}
|
}
|
||||||
|
@ -77,13 +153,8 @@ final class HomeViewModel: ViewModel, Stateful {
|
||||||
|
|
||||||
private func refresh() async throws {
|
private func refresh() async throws {
|
||||||
|
|
||||||
Task {
|
await nextUpViewModel.send(.refresh)
|
||||||
await nextUpViewModel.send(.refresh)
|
await recentlyAddedViewModel.send(.refresh)
|
||||||
}
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await recentlyAddedViewModel.send(.refresh)
|
|
||||||
}
|
|
||||||
|
|
||||||
let resumeItems = try await getResumeItems()
|
let resumeItems = try await getResumeItems()
|
||||||
let libraries = try await getLibraries()
|
let libraries = try await getLibraries()
|
||||||
|
@ -124,8 +195,7 @@ final class HomeViewModel: ViewModel, Stateful {
|
||||||
.map { LatestInLibraryViewModel(parent: $0) }
|
.map { LatestInLibraryViewModel(parent: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: eventually a more robust user/server information retrieval system
|
// TODO: use the more updated server/user data when implemented
|
||||||
// will be in place. Replace with using the data from the remove user
|
|
||||||
private func getExcludedLibraries() async throws -> [String] {
|
private func getExcludedLibraries() async throws -> [String] {
|
||||||
let currentUserPath = Paths.getCurrentUser
|
let currentUserPath = Paths.getCurrentUser
|
||||||
let response = try await userSession.client.send(currentUserPath)
|
let response = try await userSession.client.send(currentUserPath)
|
||||||
|
@ -133,38 +203,21 @@ final class HomeViewModel: ViewModel, Stateful {
|
||||||
return response.value.configuration?.latestItemsExcludes ?? []
|
return response.value.configuration?.latestItemsExcludes ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: fix
|
private func setIsPlayed(_ isPlayed: Bool, for item: BaseItemDto) async throws {
|
||||||
func markItemUnplayed(_ item: BaseItemDto) {
|
let request: Request<UserItemDataDto>
|
||||||
// guard resumeItems.contains(where: { $0.id == item.id! }) else { return }
|
|
||||||
//
|
|
||||||
// Task {
|
|
||||||
// let request = Paths.markUnplayedItem(
|
|
||||||
// userID: userSession.user.id,
|
|
||||||
// itemID: item.id!
|
|
||||||
// )
|
|
||||||
// let _ = try await userSession.client.send(request)
|
|
||||||
//
|
|
||||||
//// refreshResumeItems()
|
|
||||||
//
|
|
||||||
// try await nextUpViewModel.refresh()
|
|
||||||
// try await recentlyAddedViewModel.refresh()
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: fix
|
if isPlayed {
|
||||||
func markItemPlayed(_ item: BaseItemDto) {
|
request = Paths.markPlayedItem(
|
||||||
// guard resumeItems.contains(where: { $0.id == item.id! }) else { return }
|
userID: userSession.user.id,
|
||||||
//
|
itemID: item.id!
|
||||||
// Task {
|
)
|
||||||
// let request = Paths.markPlayedItem(
|
} else {
|
||||||
// userID: userSession.user.id,
|
request = Paths.markUnplayedItem(
|
||||||
// itemID: item.id!
|
userID: userSession.user.id,
|
||||||
// )
|
itemID: item.id!
|
||||||
// let _ = try await userSession.client.send(request)
|
)
|
||||||
//
|
}
|
||||||
//// refreshResumeItems()
|
|
||||||
// try await nextUpViewModel.refresh()
|
let _ = try await userSession.client.send(request)
|
||||||
// try await recentlyAddedViewModel.refresh()
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,27 +13,28 @@ import JellyfinAPI
|
||||||
final class CollectionItemViewModel: ItemViewModel {
|
final class CollectionItemViewModel: ItemViewModel {
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var collectionItems: [BaseItemDto] = []
|
private(set) var collectionItems: [BaseItemDto] = []
|
||||||
|
|
||||||
override init(item: BaseItemDto) {
|
override func onRefresh() async throws {
|
||||||
super.init(item: item)
|
let collectionItems = try await self.getCollectionItems()
|
||||||
|
|
||||||
getCollectionItems()
|
await MainActor.run {
|
||||||
}
|
self.collectionItems = collectionItems
|
||||||
|
|
||||||
private func getCollectionItems() {
|
|
||||||
Task {
|
|
||||||
let parameters = Paths.GetItemsParameters(
|
|
||||||
userID: userSession.user.id,
|
|
||||||
parentID: item.id,
|
|
||||||
fields: ItemFields.allCases
|
|
||||||
)
|
|
||||||
let request = Paths.getItems(parameters: parameters)
|
|
||||||
let response = try await userSession.client.send(request)
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
collectionItems = response.value.items ?? []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getCollectionItems() async throws -> [BaseItemDto] {
|
||||||
|
|
||||||
|
var parameters = Paths.GetItemsByUserIDParameters()
|
||||||
|
parameters.fields = .MinimumFields
|
||||||
|
parameters.parentID = item.id
|
||||||
|
|
||||||
|
let request = Paths.getItemsByUserID(
|
||||||
|
userID: userSession.user.id,
|
||||||
|
parameters: parameters
|
||||||
|
)
|
||||||
|
let response = try await userSession.client.send(request)
|
||||||
|
|
||||||
|
return response.value.items ?? []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,32 +14,51 @@ import Stinsen
|
||||||
final class EpisodeItemViewModel: ItemViewModel {
|
final class EpisodeItemViewModel: ItemViewModel {
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var seriesItem: BaseItemDto?
|
private(set) var seriesItem: BaseItemDto?
|
||||||
|
|
||||||
|
private var seriesItemTask: AnyCancellable?
|
||||||
|
|
||||||
override init(item: BaseItemDto) {
|
override init(item: BaseItemDto) {
|
||||||
super.init(item: item)
|
super.init(item: item)
|
||||||
|
|
||||||
getSeriesItem()
|
$lastAction
|
||||||
|
.sink { [weak self] action in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
if action == .refresh {
|
||||||
|
seriesItemTask?.cancel()
|
||||||
|
|
||||||
|
seriesItemTask = Task {
|
||||||
|
let seriesItem = try await self.getSeriesItem()
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.seriesItem = seriesItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.asAnyCancellable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func updateItem() {}
|
private func getSeriesItem() async throws -> BaseItemDto {
|
||||||
|
|
||||||
private func getSeriesItem() {
|
guard let seriesID = item.seriesID else { throw JellyfinAPIError("Expected series ID missing") }
|
||||||
guard let seriesID = item.seriesID else { return }
|
|
||||||
Task {
|
|
||||||
let parameters = Paths.GetItemsParameters(
|
|
||||||
userID: userSession.user.id,
|
|
||||||
limit: 1,
|
|
||||||
fields: ItemFields.allCases,
|
|
||||||
enableUserData: true,
|
|
||||||
ids: [seriesID]
|
|
||||||
)
|
|
||||||
let request = Paths.getItems(parameters: parameters)
|
|
||||||
let response = try await userSession.client.send(request)
|
|
||||||
|
|
||||||
await MainActor.run {
|
var parameters = Paths.GetItemsByUserIDParameters()
|
||||||
seriesItem = response.value.items?.first
|
parameters.enableUserData = true
|
||||||
}
|
parameters.fields = .MinimumFields
|
||||||
}
|
parameters.ids = [seriesID]
|
||||||
|
parameters.limit = 1
|
||||||
|
|
||||||
|
let request = Paths.getItemsByUserID(
|
||||||
|
userID: userSession.user.id,
|
||||||
|
parameters: parameters
|
||||||
|
)
|
||||||
|
let response = try await userSession.client.send(request)
|
||||||
|
|
||||||
|
guard let seriesItem = response.value.items?.first else { throw JellyfinAPIError("Expected series item missing") }
|
||||||
|
|
||||||
|
return seriesItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,14 +9,40 @@
|
||||||
import Combine
|
import Combine
|
||||||
import Factory
|
import Factory
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Get
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
import OrderedCollections
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// TODO: transition to `Stateful`
|
class ItemViewModel: ViewModel, Stateful {
|
||||||
class ItemViewModel: ViewModel {
|
|
||||||
|
// MARK: Action
|
||||||
|
|
||||||
|
enum Action: Equatable {
|
||||||
|
case backgroundRefresh
|
||||||
|
case error(JellyfinAPIError)
|
||||||
|
case refresh
|
||||||
|
case toggleIsFavorite
|
||||||
|
case toggleIsPlayed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: BackgroundState
|
||||||
|
|
||||||
|
enum BackgroundState: Hashable {
|
||||||
|
case refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: State
|
||||||
|
|
||||||
|
enum State: Hashable {
|
||||||
|
case content
|
||||||
|
case error(JellyfinAPIError)
|
||||||
|
case initial
|
||||||
|
case refreshing
|
||||||
|
}
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var item: BaseItemDto {
|
private(set) var item: BaseItemDto {
|
||||||
willSet {
|
willSet {
|
||||||
switch item.type {
|
switch item.type {
|
||||||
case .episode, .movie:
|
case .episode, .movie:
|
||||||
|
@ -37,169 +63,273 @@ class ItemViewModel: ViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var isFavorited = false
|
private(set) var selectedMediaSource: MediaSourceInfo?
|
||||||
@Published
|
@Published
|
||||||
var isPlayed = false
|
private(set) var similarItems: [BaseItemDto] = []
|
||||||
@Published
|
@Published
|
||||||
var selectedMediaSource: MediaSourceInfo?
|
private(set) var specialFeatures: [BaseItemDto] = []
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var similarItems: [BaseItemDto] = []
|
final var backgroundStates: OrderedSet<BackgroundState> = []
|
||||||
@Published
|
@Published
|
||||||
var specialFeatures: [BaseItemDto] = []
|
final var lastAction: Action? = nil
|
||||||
|
@Published
|
||||||
|
final var state: State = .initial
|
||||||
|
|
||||||
|
// tasks
|
||||||
|
|
||||||
|
private var toggleIsFavoriteTask: AnyCancellable?
|
||||||
|
private var toggleIsPlayedTask: AnyCancellable?
|
||||||
|
private var refreshTask: AnyCancellable?
|
||||||
|
|
||||||
|
// MARK: init
|
||||||
|
|
||||||
init(item: BaseItemDto) {
|
init(item: BaseItemDto) {
|
||||||
self.item = item
|
self.item = item
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
getFullItem()
|
// TODO: should replace with a more robust "PlaybackManager"
|
||||||
|
Notifications[.itemMetadataDidChange].publisher
|
||||||
|
.sink { [weak self] notification in
|
||||||
|
|
||||||
isFavorited = item.userData?.isFavorite ?? false
|
guard let userInfo = notification.object as? [String: String] else { return }
|
||||||
isPlayed = item.userData?.isPlayed ?? false
|
|
||||||
|
|
||||||
getSimilarItems()
|
if let itemID = userInfo["itemID"], itemID == item.id {
|
||||||
getSpecialFeatures()
|
Task { [weak self] in
|
||||||
|
await self?.send(.backgroundRefresh)
|
||||||
Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:)))
|
}
|
||||||
}
|
} else if let seriesID = userInfo["seriesID"], seriesID == item.id {
|
||||||
|
Task { [weak self] in
|
||||||
private func getFullItem() {
|
await self?.send(.backgroundRefresh)
|
||||||
Task {
|
}
|
||||||
|
}
|
||||||
await MainActor.run {
|
|
||||||
isLoading = true
|
|
||||||
}
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
let parameters = Paths.GetItemsParameters(
|
// MARK: respond
|
||||||
userID: userSession.user.id,
|
|
||||||
fields: ItemFields.allCases,
|
|
||||||
enableUserData: true,
|
|
||||||
ids: [item.id!]
|
|
||||||
)
|
|
||||||
|
|
||||||
let request = Paths.getItems(parameters: parameters)
|
func respond(to action: Action) -> State {
|
||||||
let response = try await userSession.client.send(request)
|
switch action {
|
||||||
|
case .backgroundRefresh:
|
||||||
|
|
||||||
guard let fullItem = response.value.items?.first else { return }
|
backgroundStates.append(.refresh)
|
||||||
|
|
||||||
await MainActor.run {
|
Task { [weak self] in
|
||||||
self.item = fullItem
|
guard let self else { return }
|
||||||
isLoading = false
|
do {
|
||||||
|
async let fullItem = getFullItem()
|
||||||
|
async let similarItems = getSimilarItems()
|
||||||
|
async let specialFeatures = getSpecialFeatures()
|
||||||
|
|
||||||
|
let results = try await (
|
||||||
|
fullItem: fullItem,
|
||||||
|
similarItems: similarItems,
|
||||||
|
specialFeatures: specialFeatures
|
||||||
|
)
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
try await onRefresh()
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.backgroundStates.remove(.refresh)
|
||||||
|
self.item = results.fullItem
|
||||||
|
self.similarItems = results.similarItems
|
||||||
|
self.specialFeatures = results.specialFeatures
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.backgroundStates.remove(.refresh)
|
||||||
|
self.send(.error(.init(error.localizedDescription)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.store(in: &cancellables)
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
return state
|
||||||
private func receivedStopReport(_ notification: NSNotification) {
|
case let .error(error):
|
||||||
guard let itemID = notification.object as? String else { return }
|
return .error(error)
|
||||||
|
case .refresh:
|
||||||
|
|
||||||
if itemID == item.id {
|
refreshTask?.cancel()
|
||||||
updateItem()
|
|
||||||
} else {
|
|
||||||
// Remove if necessary. Note that this cannot be in deinit as
|
|
||||||
// holding as an observer won't allow the object to be deinit-ed
|
|
||||||
Notifications[.didSendStopReport].unsubscribe(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: remove and have views handle
|
refreshTask = Task { [weak self] in
|
||||||
func playButtonText() -> String {
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
async let fullItem = getFullItem()
|
||||||
|
async let similarItems = getSimilarItems()
|
||||||
|
async let specialFeatures = getSpecialFeatures()
|
||||||
|
|
||||||
if item.isUnaired {
|
let results = try await (
|
||||||
return L10n.unaired
|
fullItem: fullItem,
|
||||||
}
|
similarItems: similarItems,
|
||||||
|
specialFeatures: specialFeatures
|
||||||
|
)
|
||||||
|
|
||||||
if item.isMissing {
|
guard !Task.isCancelled else { return }
|
||||||
return L10n.missing
|
|
||||||
}
|
|
||||||
|
|
||||||
if let itemProgressString = item.progressLabel {
|
try await onRefresh()
|
||||||
return itemProgressString
|
|
||||||
}
|
|
||||||
|
|
||||||
return L10n.play
|
guard !Task.isCancelled else { return }
|
||||||
}
|
|
||||||
|
|
||||||
func getSimilarItems() {
|
await MainActor.run {
|
||||||
Task {
|
self.item = results.fullItem
|
||||||
let parameters = Paths.GetSimilarItemsParameters(
|
self.similarItems = results.similarItems
|
||||||
userID: userSession.user.id,
|
self.specialFeatures = results.specialFeatures
|
||||||
limit: 20,
|
|
||||||
fields: .MinimumFields
|
|
||||||
)
|
|
||||||
let request = Paths.getSimilarItems(
|
|
||||||
itemID: item.id!,
|
|
||||||
parameters: parameters
|
|
||||||
)
|
|
||||||
let response = try await userSession.client.send(request)
|
|
||||||
|
|
||||||
await MainActor.run {
|
self.state = .content
|
||||||
similarItems = response.value.items ?? []
|
}
|
||||||
|
} catch {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.send(.error(.init(error.localizedDescription)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.asAnyCancellable()
|
||||||
|
|
||||||
|
return .refreshing
|
||||||
|
case .toggleIsFavorite:
|
||||||
|
|
||||||
|
toggleIsFavoriteTask?.cancel()
|
||||||
|
|
||||||
|
toggleIsFavoriteTask = Task {
|
||||||
|
|
||||||
|
let beforeIsFavorite = item.userData?.isFavorite ?? false
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
item.userData?.isFavorite?.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await setIsFavorite(!beforeIsFavorite)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
item.userData?.isFavorite = beforeIsFavorite
|
||||||
|
// emit event that toggle unsuccessful
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.asAnyCancellable()
|
||||||
|
|
||||||
|
return state
|
||||||
|
case .toggleIsPlayed:
|
||||||
|
|
||||||
|
toggleIsPlayedTask?.cancel()
|
||||||
|
|
||||||
|
toggleIsPlayedTask = Task {
|
||||||
|
|
||||||
|
let beforeIsPlayed = item.userData?.isPlayed ?? false
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
item.userData?.isPlayed?.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await setIsPlayed(!beforeIsPlayed)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
item.userData?.isPlayed = beforeIsPlayed
|
||||||
|
// emit event that toggle unsuccessful
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.asAnyCancellable()
|
||||||
|
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSpecialFeatures() {
|
func onRefresh() async throws {}
|
||||||
Task {
|
|
||||||
let request = Paths.getSpecialFeatures(
|
private func getFullItem() async throws -> BaseItemDto {
|
||||||
|
|
||||||
|
var parameters = Paths.GetItemsByUserIDParameters()
|
||||||
|
parameters.enableUserData = true
|
||||||
|
parameters.fields = ItemFields.allCases
|
||||||
|
parameters.ids = [item.id!]
|
||||||
|
|
||||||
|
let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters)
|
||||||
|
let response = try await userSession.client.send(request)
|
||||||
|
|
||||||
|
guard let fullItem = response.value.items?.first else { throw JellyfinAPIError("Full item not in response") }
|
||||||
|
|
||||||
|
return fullItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getSimilarItems() async -> [BaseItemDto] {
|
||||||
|
|
||||||
|
var parameters = Paths.GetSimilarItemsParameters()
|
||||||
|
parameters.fields = .MinimumFields
|
||||||
|
parameters.limit = 20
|
||||||
|
parameters.userID = userSession.user.id
|
||||||
|
|
||||||
|
let request = Paths.getSimilarItems(
|
||||||
|
itemID: item.id!,
|
||||||
|
parameters: parameters
|
||||||
|
)
|
||||||
|
|
||||||
|
let response = try? await userSession.client.send(request)
|
||||||
|
|
||||||
|
return response?.value.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getSpecialFeatures() async -> [BaseItemDto] {
|
||||||
|
|
||||||
|
let request = Paths.getSpecialFeatures(
|
||||||
|
userID: userSession.user.id,
|
||||||
|
itemID: item.id!
|
||||||
|
)
|
||||||
|
let response = try? await userSession.client.send(request)
|
||||||
|
|
||||||
|
return (response?.value ?? [])
|
||||||
|
.filter { $0.extraType?.isVideo ?? false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setIsPlayed(_ isPlayed: Bool) async throws {
|
||||||
|
|
||||||
|
let request: Request<UserItemDataDto>
|
||||||
|
|
||||||
|
if isPlayed {
|
||||||
|
request = Paths.markPlayedItem(
|
||||||
|
userID: userSession.user.id,
|
||||||
|
itemID: item.id!
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
request = Paths.markUnplayedItem(
|
||||||
userID: userSession.user.id,
|
userID: userSession.user.id,
|
||||||
itemID: item.id!
|
itemID: item.id!
|
||||||
)
|
)
|
||||||
let response = try await userSession.client.send(request)
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
specialFeatures = response.value.filter { $0.extraType?.isVideo ?? false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = try await userSession.client.send(request)
|
||||||
|
|
||||||
|
let ids = ["itemID": item.id]
|
||||||
|
Notifications[.itemMetadataDidChange].post(object: ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleWatchState() {
|
private func setIsFavorite(_ isFavorite: Bool) async throws {
|
||||||
// let current = isPlayed
|
|
||||||
// isPlayed.toggle()
|
|
||||||
// let request: AnyPublisher<UserItemDataDto, Error>
|
|
||||||
|
|
||||||
// if current {
|
let request: Request<UserItemDataDto>
|
||||||
// request = PlaystateAPI.markUnplayedItem(userId: "123abc", itemId: item.id!)
|
|
||||||
// } else {
|
|
||||||
// request = PlaystateAPI.markPlayedItem(userId: "123abc", itemId: item.id!)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// request
|
if isFavorite {
|
||||||
// .trackActivity(loading)
|
request = Paths.markFavoriteItem(
|
||||||
// .sink(receiveCompletion: { [weak self] completion in
|
userID: userSession.user.id,
|
||||||
// switch completion {
|
itemID: item.id!
|
||||||
// case .failure:
|
)
|
||||||
// self?.isPlayed = !current
|
} else {
|
||||||
// case .finished: ()
|
request = Paths.unmarkFavoriteItem(
|
||||||
// }
|
userID: userSession.user.id,
|
||||||
// self?.handleAPIRequestError(completion: completion)
|
itemID: item.id!
|
||||||
// }, receiveValue: { _ in })
|
)
|
||||||
// .store(in: &cancellables)
|
}
|
||||||
|
|
||||||
|
let _ = try await userSession.client.send(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleFavoriteState() {
|
|
||||||
// let current = isFavorited
|
|
||||||
// isFavorited.toggle()
|
|
||||||
// let request: AnyPublisher<UserItemDataDto, Error>
|
|
||||||
|
|
||||||
// if current {
|
|
||||||
// request = UserLibraryAPI.unmarkFavoriteItem(userId: "123abc", itemId: item.id!)
|
|
||||||
// } else {
|
|
||||||
// request = UserLibraryAPI.markFavoriteItem(userId: "123abc", itemId: item.id!)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// request
|
|
||||||
// .trackActivity(loading)
|
|
||||||
// .sink(receiveCompletion: { [weak self] completion in
|
|
||||||
// switch completion {
|
|
||||||
// case .failure:
|
|
||||||
// self?.isFavorited = !current
|
|
||||||
// case .finished: ()
|
|
||||||
// }
|
|
||||||
// self?.handleAPIRequestError(completion: completion)
|
|
||||||
// }, receiveValue: { _ in })
|
|
||||||
// .store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overridden by subclasses
|
|
||||||
func updateItem() {}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,4 @@ import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
final class MovieItemViewModel: ItemViewModel {
|
final class MovieItemViewModel: ItemViewModel {}
|
||||||
|
|
||||||
override func updateItem() {}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
// Since we don't view care to view seasons directly, this doesn't subclass from `ItemViewModel`.
|
||||||
|
// If we ever care for viewing seasons directly, subclass from that and have the library view model
|
||||||
|
// as a property.
|
||||||
|
final class SeasonItemViewModel: PagingLibraryViewModel<BaseItemDto> {
|
||||||
|
|
||||||
|
let season: BaseItemDto
|
||||||
|
|
||||||
|
init(season: BaseItemDto) {
|
||||||
|
self.season = season
|
||||||
|
super.init(parent: season)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func get(page: Int) async throws -> [BaseItemDto] {
|
||||||
|
|
||||||
|
var parameters = Paths.GetEpisodesParameters()
|
||||||
|
parameters.enableUserData = true
|
||||||
|
parameters.fields = .MinimumFields
|
||||||
|
parameters.isMissing = Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false
|
||||||
|
parameters.seasonID = parent!.id
|
||||||
|
parameters.userID = userSession.user.id
|
||||||
|
|
||||||
|
// parameters.startIndex = page * pageSize
|
||||||
|
// parameters.limit = pageSize
|
||||||
|
|
||||||
|
let request = Paths.getEpisodes(
|
||||||
|
seriesID: parent!.id!,
|
||||||
|
parameters: parameters
|
||||||
|
)
|
||||||
|
let response = try await userSession.client.send(request)
|
||||||
|
|
||||||
|
return response.value.items ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SeasonItemViewModel: Hashable {
|
||||||
|
|
||||||
|
static func == (lhs: SeasonItemViewModel, rhs: SeasonItemViewModel) -> Bool {
|
||||||
|
lhs.parent as! BaseItemDto == rhs.parent as! BaseItemDto
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine((parent as! BaseItemDto).hashValue)
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,170 +13,121 @@ import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import OrderedCollections
|
import OrderedCollections
|
||||||
|
|
||||||
// TODO: use OrderedDictionary
|
// TODO: care for one long episodes list?
|
||||||
|
// - after SeasonItemViewModel is bidirectional
|
||||||
|
// - would have to see if server returns right amount of episodes/season
|
||||||
final class SeriesItemViewModel: ItemViewModel {
|
final class SeriesItemViewModel: ItemViewModel {
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var menuSelection: BaseItemDto?
|
var seasons: OrderedSet<SeasonItemViewModel> = []
|
||||||
@Published
|
|
||||||
var currentItems: OrderedSet<BaseItemDto> = []
|
|
||||||
|
|
||||||
var menuSections: [BaseItemDto: OrderedSet<BaseItemDto>]
|
override func onRefresh() async throws {
|
||||||
var menuSectionSort: (BaseItemDto, BaseItemDto) -> Bool
|
|
||||||
|
|
||||||
override init(item: BaseItemDto) {
|
await MainActor.run {
|
||||||
self.menuSections = [:]
|
self.seasons.removeAll()
|
||||||
self.menuSectionSort = { i, j in i.indexNumber ?? -1 < j.indexNumber ?? -1 }
|
|
||||||
|
|
||||||
super.init(item: item)
|
|
||||||
|
|
||||||
getSeasons()
|
|
||||||
|
|
||||||
// The server won't have both a next up item
|
|
||||||
// and a resume item at the same time, so they
|
|
||||||
// control the button first. Also fetch first available
|
|
||||||
// item, which may be overwritten by next up or resume.
|
|
||||||
getNextUp()
|
|
||||||
getResumeItem()
|
|
||||||
getFirstAvailableItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func playButtonText() -> String {
|
|
||||||
|
|
||||||
if item.isUnaired {
|
|
||||||
return L10n.unaired
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.isMissing {
|
async let nextUp = getNextUp()
|
||||||
return L10n.missing
|
async let resume = getResumeItem()
|
||||||
|
async let firstAvailable = getFirstAvailableItem()
|
||||||
|
async let seasons = getSeasons()
|
||||||
|
|
||||||
|
let newSeasons = try await seasons
|
||||||
|
.sorted { ($0.indexNumber ?? -1) < ($1.indexNumber ?? -1) } // sort just in case
|
||||||
|
.map(SeasonItemViewModel.init)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.seasons.append(contentsOf: newSeasons)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let playButtonItem = playButtonItem,
|
if let episodeItem = try await [nextUp, resume].compacted().first {
|
||||||
let episodeLocator = playButtonItem.seasonEpisodeLabel else { return L10n.play }
|
|
||||||
|
|
||||||
return episodeLocator
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getNextUp() {
|
|
||||||
Task {
|
|
||||||
let parameters = Paths.GetNextUpParameters(
|
|
||||||
userID: userSession.user.id,
|
|
||||||
fields: .MinimumFields,
|
|
||||||
seriesID: item.id,
|
|
||||||
enableUserData: true
|
|
||||||
)
|
|
||||||
let request = Paths.getNextUp(parameters: parameters)
|
|
||||||
let response = try await userSession.client.send(request)
|
|
||||||
|
|
||||||
if let item = response.value.items?.first, !item.isMissing {
|
|
||||||
await MainActor.run {
|
|
||||||
self.playButtonItem = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getResumeItem() {
|
|
||||||
Task {
|
|
||||||
let parameters = Paths.GetResumeItemsParameters(
|
|
||||||
limit: 1,
|
|
||||||
parentID: item.id,
|
|
||||||
fields: .MinimumFields
|
|
||||||
)
|
|
||||||
let request = Paths.getResumeItems(userID: userSession.user.id, parameters: parameters)
|
|
||||||
let response = try await userSession.client.send(request)
|
|
||||||
|
|
||||||
if let item = response.value.items?.first {
|
|
||||||
await MainActor.run {
|
|
||||||
self.playButtonItem = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getFirstAvailableItem() {
|
|
||||||
Task {
|
|
||||||
let parameters = Paths.GetItemsParameters(
|
|
||||||
userID: userSession.user.id,
|
|
||||||
limit: 1,
|
|
||||||
isRecursive: true,
|
|
||||||
sortOrder: [.ascending],
|
|
||||||
parentID: item.id,
|
|
||||||
fields: .MinimumFields,
|
|
||||||
includeItemTypes: [.episode]
|
|
||||||
)
|
|
||||||
let request = Paths.getItems(parameters: parameters)
|
|
||||||
let response = try await userSession.client.send(request)
|
|
||||||
|
|
||||||
if let item = response.value.items?.first {
|
|
||||||
if self.playButtonItem == nil {
|
|
||||||
await MainActor.run {
|
|
||||||
self.playButtonItem = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func select(section: BaseItemDto) {
|
|
||||||
self.menuSelection = section
|
|
||||||
|
|
||||||
if let episodes = menuSections[section] {
|
|
||||||
if episodes.isEmpty {
|
|
||||||
getEpisodesForSeason(section)
|
|
||||||
} else {
|
|
||||||
self.currentItems = episodes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getSeasons() {
|
|
||||||
Task {
|
|
||||||
let parameters = Paths.GetSeasonsParameters(
|
|
||||||
userID: userSession.user.id,
|
|
||||||
isMissing: Defaults[.Customization.shouldShowMissingSeasons] ? nil : false
|
|
||||||
)
|
|
||||||
let request = Paths.getSeasons(seriesID: item.id!, parameters: parameters)
|
|
||||||
let response = try await userSession.client.send(request)
|
|
||||||
|
|
||||||
guard let seasons = response.value.items else { return }
|
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
for season in seasons {
|
self.playButtonItem = episodeItem
|
||||||
self.menuSections[season] = []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if let firstAvailable = try await firstAvailable {
|
||||||
if let firstSeason = seasons.first {
|
await MainActor.run {
|
||||||
self.getEpisodesForSeason(firstSeason)
|
self.playButtonItem = firstAvailable
|
||||||
await MainActor.run {
|
|
||||||
self.menuSelection = firstSeason
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: implement lazy loading
|
// override func playButtonText() -> String {
|
||||||
private func getEpisodesForSeason(_ season: BaseItemDto) {
|
//
|
||||||
Task {
|
// if item.isUnaired {
|
||||||
let parameters = Paths.GetEpisodesParameters(
|
// return L10n.unaired
|
||||||
userID: userSession.user.id,
|
// }
|
||||||
fields: .MinimumFields,
|
//
|
||||||
seasonID: season.id!,
|
// if item.isMissing {
|
||||||
isMissing: Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false,
|
// return L10n.missing
|
||||||
enableUserData: true
|
// }
|
||||||
)
|
//
|
||||||
let request = Paths.getEpisodes(seriesID: item.id!, parameters: parameters)
|
// guard let playButtonItem = playButtonItem,
|
||||||
let response = try await userSession.client.send(request)
|
// let episodeLocator = playButtonItem.seasonEpisodeLabel else { return L10n.play }
|
||||||
|
//
|
||||||
|
// return episodeLocator
|
||||||
|
// }
|
||||||
|
|
||||||
await MainActor.run {
|
private func getNextUp() async throws -> BaseItemDto? {
|
||||||
if let items = response.value.items {
|
|
||||||
let newItems = OrderedSet(items)
|
var parameters = Paths.GetNextUpParameters()
|
||||||
self.menuSections[season] = newItems
|
parameters.fields = .MinimumFields
|
||||||
self.currentItems = newItems
|
parameters.seriesID = item.id
|
||||||
}
|
parameters.userID = userSession.user.id
|
||||||
}
|
|
||||||
|
let request = Paths.getNextUp(parameters: parameters)
|
||||||
|
let response = try await userSession.client.send(request)
|
||||||
|
|
||||||
|
guard let item = response.value.items?.first, !item.isMissing else {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getResumeItem() async throws -> BaseItemDto? {
|
||||||
|
|
||||||
|
var parameters = Paths.GetResumeItemsParameters()
|
||||||
|
parameters.fields = .MinimumFields
|
||||||
|
parameters.limit = 1
|
||||||
|
parameters.parentID = item.id
|
||||||
|
|
||||||
|
let request = Paths.getResumeItems(userID: userSession.user.id, parameters: parameters)
|
||||||
|
let response = try await userSession.client.send(request)
|
||||||
|
|
||||||
|
return response.value.items?.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getFirstAvailableItem() async throws -> BaseItemDto? {
|
||||||
|
|
||||||
|
var parameters = Paths.GetItemsByUserIDParameters()
|
||||||
|
parameters.fields = .MinimumFields
|
||||||
|
parameters.includeItemTypes = [.episode]
|
||||||
|
parameters.isRecursive = true
|
||||||
|
parameters.limit = 1
|
||||||
|
parameters.parentID = item.id
|
||||||
|
parameters.sortOrder = [.ascending]
|
||||||
|
|
||||||
|
let request = Paths.getItemsByUserID(
|
||||||
|
userID: userSession.user.id,
|
||||||
|
parameters: parameters
|
||||||
|
)
|
||||||
|
let response = try await userSession.client.send(request)
|
||||||
|
|
||||||
|
return response.value.items?.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getSeasons() async throws -> [BaseItemDto] {
|
||||||
|
|
||||||
|
var parameters = Paths.GetSeasonsParameters()
|
||||||
|
parameters.isMissing = Defaults[.Customization.shouldShowMissingSeasons] ? nil : false
|
||||||
|
parameters.userID = userSession.user.id
|
||||||
|
|
||||||
|
let request = Paths.getSeasons(
|
||||||
|
seriesID: item.id!,
|
||||||
|
parameters: parameters
|
||||||
|
)
|
||||||
|
let response = try await userSession.client.send(request)
|
||||||
|
|
||||||
|
return response.value.items ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,18 +36,4 @@ final class NextUpLibraryViewModel: PagingLibraryViewModel<BaseItemDto> {
|
||||||
|
|
||||||
return parameters
|
return parameters
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: fix
|
|
||||||
func markPlayed(item: BaseItemDto) {
|
|
||||||
// Task {
|
|
||||||
//
|
|
||||||
// let request = Paths.markPlayedItem(
|
|
||||||
// userID: userSession.user.id,
|
|
||||||
// itemID: item.id!
|
|
||||||
// )
|
|
||||||
// let _ = try await userSession.client.send(request)
|
|
||||||
//
|
|
||||||
// try await refresh()
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,11 +20,13 @@ private let DefaultPageSize = 50
|
||||||
// and I don't want additional views for it. Is there a way we can transform a
|
// and I don't want additional views for it. Is there a way we can transform a
|
||||||
// `BaseItemPerson` into a `BaseItemDto` and just use the concrete type?
|
// `BaseItemPerson` into a `BaseItemDto` and just use the concrete type?
|
||||||
|
|
||||||
// TODO: how to indicate that this is performing some kind of background action (ie: RandomItem)
|
|
||||||
// *without* being in an explicit state?
|
|
||||||
// TODO: fix how `hasNextPage` is determined
|
// TODO: fix how `hasNextPage` is determined
|
||||||
// - some subclasses might not have "paging" and only have one call. This can be solved with
|
// - some subclasses might not have "paging" and only have one call. This can be solved with
|
||||||
// a check if elements were actually appended to the set but that requires a redundant get
|
// a check if elements were actually appended to the set but that requires a redundant get
|
||||||
|
// TODO: this doesn't allow "scrolling" to an item if index > pageSize
|
||||||
|
// on refresh. Should make bidirectional/offset index start?
|
||||||
|
// - use startIndex/index ranges instead of pages
|
||||||
|
// - source of data doesn't guarantee that all items in 0 ..< startIndex exist
|
||||||
class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
|
|
||||||
// MARK: Event
|
// MARK: Event
|
||||||
|
@ -36,42 +38,35 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
// MARK: Action
|
// MARK: Action
|
||||||
|
|
||||||
enum Action: Equatable {
|
enum Action: Equatable {
|
||||||
case error(LibraryError)
|
case error(JellyfinAPIError)
|
||||||
case refresh
|
case refresh
|
||||||
case getNextPage
|
case getNextPage
|
||||||
case getRandomItem
|
case getRandomItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: BackgroundState
|
||||||
|
|
||||||
|
enum BackgroundState: Hashable {
|
||||||
|
case gettingNextPage
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: State
|
// MARK: State
|
||||||
|
|
||||||
enum State: Equatable {
|
enum State: Hashable {
|
||||||
case content
|
case content
|
||||||
case error(LibraryError)
|
case error(JellyfinAPIError)
|
||||||
case gettingNextPage
|
|
||||||
case initial
|
case initial
|
||||||
case refreshing
|
case refreshing
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: wrap Get HTTP and NSURL errors either here
|
@Published
|
||||||
// or in a general implementation
|
final var backgroundStates: OrderedSet<BackgroundState> = []
|
||||||
enum LibraryError: LocalizedError {
|
|
||||||
case unableToGetPage
|
|
||||||
case unableToGetRandomItem
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .unableToGetPage:
|
|
||||||
"Unable to get page"
|
|
||||||
case .unableToGetRandomItem:
|
|
||||||
"Unable to get random item"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
final var elements: OrderedSet<Element>
|
final var elements: OrderedSet<Element>
|
||||||
@Published
|
@Published
|
||||||
final var state: State = .initial
|
final var state: State = .initial
|
||||||
|
@Published
|
||||||
|
final var lastAction: Action? = nil
|
||||||
|
|
||||||
final let filterViewModel: FilterViewModel?
|
final let filterViewModel: FilterViewModel?
|
||||||
final let parent: (any LibraryParent)?
|
final let parent: (any LibraryParent)?
|
||||||
|
@ -97,19 +92,27 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
|
|
||||||
// MARK: init
|
// MARK: init
|
||||||
|
|
||||||
|
// static
|
||||||
init(
|
init(
|
||||||
_ data: some Collection<Element>,
|
_ data: some Collection<Element>,
|
||||||
parent: (any LibraryParent)? = nil,
|
parent: (any LibraryParent)? = nil
|
||||||
pageSize: Int = DefaultPageSize
|
|
||||||
) {
|
) {
|
||||||
self.filterViewModel = nil
|
self.filterViewModel = nil
|
||||||
self.elements = OrderedSet(data)
|
self.elements = OrderedSet(data)
|
||||||
self.isStatic = true
|
self.isStatic = true
|
||||||
self.hasNextPage = false
|
self.hasNextPage = false
|
||||||
self.pageSize = pageSize
|
self.pageSize = DefaultPageSize
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
convenience init(
|
||||||
|
title: String,
|
||||||
|
_ data: some Collection<Element>
|
||||||
|
) {
|
||||||
|
self.init(data, parent: TitledLibraryParent(displayTitle: title))
|
||||||
|
}
|
||||||
|
|
||||||
|
// paging
|
||||||
init(
|
init(
|
||||||
parent: (any LibraryParent)? = nil,
|
parent: (any LibraryParent)? = nil,
|
||||||
filters: ItemFilterCollection? = nil,
|
filters: ItemFilterCollection? = nil,
|
||||||
|
@ -152,7 +155,11 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
filters: ItemFilterCollection = .default,
|
filters: ItemFilterCollection = .default,
|
||||||
pageSize: Int = DefaultPageSize
|
pageSize: Int = DefaultPageSize
|
||||||
) {
|
) {
|
||||||
self.init(parent: TitledLibraryParent(displayTitle: title), filters: filters, pageSize: pageSize)
|
self.init(
|
||||||
|
parent: TitledLibraryParent(displayTitle: title),
|
||||||
|
filters: filters,
|
||||||
|
pageSize: pageSize
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: respond
|
// MARK: respond
|
||||||
|
@ -198,7 +205,7 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.send(.error(.unableToGetPage))
|
self.send(.error(.init(error.localizedDescription)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -209,6 +216,8 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
|
|
||||||
guard hasNextPage else { return state }
|
guard hasNextPage else { return state }
|
||||||
|
|
||||||
|
backgroundStates.append(.gettingNextPage)
|
||||||
|
|
||||||
pagingTask = Task { [weak self] in
|
pagingTask = Task { [weak self] in
|
||||||
do {
|
do {
|
||||||
try await self?.getNextPage()
|
try await self?.getNextPage()
|
||||||
|
@ -216,19 +225,21 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
self?.backgroundStates.remove(.gettingNextPage)
|
||||||
self?.state = .content
|
self?.state = .content
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self?.state = .error(.unableToGetPage)
|
self?.backgroundStates.remove(.gettingNextPage)
|
||||||
|
self?.state = .error(.init(error.localizedDescription))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.asAnyCancellable()
|
.asAnyCancellable()
|
||||||
|
|
||||||
return .gettingNextPage
|
return .content
|
||||||
case .getRandomItem:
|
case .getRandomItem:
|
||||||
|
|
||||||
randomItemTask = Task { [weak self] in
|
randomItemTask = Task { [weak self] in
|
||||||
|
|
|
@ -18,14 +18,14 @@ final class MediaViewModel: ViewModel, Stateful {
|
||||||
|
|
||||||
// MARK: Action
|
// MARK: Action
|
||||||
|
|
||||||
enum Action {
|
enum Action: Equatable {
|
||||||
case error(JellyfinAPIError)
|
case error(JellyfinAPIError)
|
||||||
case refresh
|
case refresh
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: State
|
// MARK: State
|
||||||
|
|
||||||
enum State: Equatable {
|
enum State: Hashable {
|
||||||
case content
|
case content
|
||||||
case error(JellyfinAPIError)
|
case error(JellyfinAPIError)
|
||||||
case initial
|
case initial
|
||||||
|
@ -36,7 +36,9 @@ final class MediaViewModel: ViewModel, Stateful {
|
||||||
var mediaItems: OrderedSet<MediaType> = []
|
var mediaItems: OrderedSet<MediaType> = []
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var state: State = .initial
|
final var state: State = .initial
|
||||||
|
@Published
|
||||||
|
final var lastAction: Action? = nil
|
||||||
|
|
||||||
func respond(to action: Action) -> State {
|
func respond(to action: Action) -> State {
|
||||||
switch action {
|
switch action {
|
||||||
|
|
|
@ -9,13 +9,14 @@
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
import OrderedCollections
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class SearchViewModel: ViewModel, Stateful {
|
final class SearchViewModel: ViewModel, Stateful {
|
||||||
|
|
||||||
// MARK: Action
|
// MARK: Action
|
||||||
|
|
||||||
enum Action {
|
enum Action: Equatable {
|
||||||
case error(JellyfinAPIError)
|
case error(JellyfinAPIError)
|
||||||
case getSuggestions
|
case getSuggestions
|
||||||
case search(query: String)
|
case search(query: String)
|
||||||
|
@ -23,7 +24,7 @@ final class SearchViewModel: ViewModel, Stateful {
|
||||||
|
|
||||||
// MARK: State
|
// MARK: State
|
||||||
|
|
||||||
enum State: Equatable {
|
enum State: Hashable {
|
||||||
case content
|
case content
|
||||||
case error(JellyfinAPIError)
|
case error(JellyfinAPIError)
|
||||||
case initial
|
case initial
|
||||||
|
@ -31,20 +32,22 @@ final class SearchViewModel: ViewModel, Stateful {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var collections: [BaseItemDto] = []
|
private(set) var collections: [BaseItemDto] = []
|
||||||
@Published
|
@Published
|
||||||
var episodes: [BaseItemDto] = []
|
private(set) var episodes: [BaseItemDto] = []
|
||||||
@Published
|
@Published
|
||||||
var movies: [BaseItemDto] = []
|
private(set) var movies: [BaseItemDto] = []
|
||||||
@Published
|
@Published
|
||||||
var people: [BaseItemDto] = []
|
private(set) var people: [BaseItemDto] = []
|
||||||
@Published
|
@Published
|
||||||
var series: [BaseItemDto] = []
|
private(set) var series: [BaseItemDto] = []
|
||||||
@Published
|
@Published
|
||||||
var suggestions: [BaseItemDto] = []
|
private(set) var suggestions: [BaseItemDto] = []
|
||||||
|
|
||||||
@Published
|
@Published
|
||||||
var state: State = .initial
|
final var state: State = .initial
|
||||||
|
@Published
|
||||||
|
final var lastAction: Action? = nil
|
||||||
|
|
||||||
private var searchTask: AnyCancellable?
|
private var searchTask: AnyCancellable?
|
||||||
private var searchQuery: CurrentValueSubject<String, Never> = .init("")
|
private var searchQuery: CurrentValueSubject<String, Never> = .init("")
|
||||||
|
@ -179,9 +182,7 @@ final class SearchViewModel: ViewModel, Stateful {
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
||||||
guard !Task.isCancelled else { print("search was cancelled")
|
guard !Task.isCancelled else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.send(.error(.init(error.localizedDescription)))
|
self.send(.error(.init(error.localizedDescription)))
|
||||||
|
|
|
@ -202,6 +202,9 @@ class VideoPlayerManager: ViewModel {
|
||||||
|
|
||||||
func sendStopReport() {
|
func sendStopReport() {
|
||||||
|
|
||||||
|
let ids = ["itemID": currentViewModel.item.id, "seriesID": currentViewModel.item.parentID]
|
||||||
|
Notifications[.itemMetadataDidChange].post(object: ids)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
guard Defaults[.sendProgressReports] else { return }
|
guard Defaults[.sendProgressReports] else { return }
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -174,7 +174,7 @@ struct PagingLibraryView<Element: Poster>: View {
|
||||||
Text(error.localizedDescription)
|
Text(error.localizedDescription)
|
||||||
case .initial, .refreshing:
|
case .initial, .refreshing:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
case .gettingNextPage, .content:
|
case .content:
|
||||||
if viewModel.elements.isEmpty {
|
if viewModel.elements.isEmpty {
|
||||||
L10n.noResults.text
|
L10n.noResults.text
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -61,5 +61,11 @@ struct HomeView: View {
|
||||||
viewModel.send(.refresh)
|
viewModel.send(.refresh)
|
||||||
}
|
}
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
.afterLastDisappear { interval in
|
||||||
|
if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) {
|
||||||
|
viewModel.send(.backgroundRefresh)
|
||||||
|
viewModel.notificationsReceived.remove(.itemMetadataDidChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,10 +18,10 @@ extension ItemView {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Button {
|
Button {
|
||||||
viewModel.toggleWatchState()
|
viewModel.send(.toggleIsPlayed)
|
||||||
} label: {
|
} label: {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.isPlayed {
|
if viewModel.item.userData?.isPlayed ?? false {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.paletteOverlayRendering(color: .white)
|
.paletteOverlayRendering(color: .white)
|
||||||
} else {
|
} else {
|
||||||
|
@ -35,10 +35,10 @@ extension ItemView {
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.toggleFavoriteState()
|
viewModel.send(.toggleIsFavorite)
|
||||||
} label: {
|
} label: {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.isFavorited {
|
if viewModel.item.userData?.isFavorite ?? false {
|
||||||
Image(systemName: "heart.circle.fill")
|
Image(systemName: "heart.circle.fill")
|
||||||
.symbolRenderingMode(.palette)
|
.symbolRenderingMode(.palette)
|
||||||
.foregroundStyle(.white, .pink)
|
.foregroundStyle(.white, .pink)
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
//
|
||||||
|
// 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 Factory
|
||||||
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct EpisodeCard: View {
|
||||||
|
|
||||||
|
@EnvironmentObject
|
||||||
|
private var router: ItemCoordinator.Router
|
||||||
|
|
||||||
|
let episode: BaseItemDto
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
PosterButton(
|
||||||
|
item: episode,
|
||||||
|
type: .landscape,
|
||||||
|
singleImage: true
|
||||||
|
)
|
||||||
|
.content {
|
||||||
|
let content: String = if episode.isUnaired {
|
||||||
|
episode.airDateLabel ?? L10n.noOverviewAvailable
|
||||||
|
} else {
|
||||||
|
episode.overview ?? L10n.noOverviewAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
SeriesEpisodeSelector.EpisodeContent(
|
||||||
|
subHeader: episode.episodeLocator ?? .emptyDash,
|
||||||
|
header: episode.displayTitle,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
.onSelect {
|
||||||
|
router.route(to: \.item, episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.imageOverlay {
|
||||||
|
ZStack {
|
||||||
|
if episode.userData?.isPlayed ?? false {
|
||||||
|
WatchedIndicator(size: 45)
|
||||||
|
} else {
|
||||||
|
if (episode.userData?.playbackPositionTicks ?? 0) > 0 {
|
||||||
|
LandscapePosterProgressBar(
|
||||||
|
title: episode.progressLabel ?? L10n.continue,
|
||||||
|
progress: (episode.userData?.playedPercentage ?? 0) / 100
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSelect {
|
||||||
|
guard let mediaSource = episode.mediaSources?.first else { return }
|
||||||
|
router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: episode, mediaSource: mediaSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
//
|
||||||
|
// Swiftfin is subject to the terms of the Mozilla Public
|
||||||
|
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
|
//
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct EpisodeContent: View {
|
||||||
|
|
||||||
|
@Default(.accentColor)
|
||||||
|
private var accentColor
|
||||||
|
|
||||||
|
private var onSelect: () -> Void
|
||||||
|
|
||||||
|
let subHeader: String
|
||||||
|
let header: String
|
||||||
|
let content: String
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var subHeaderView: some View {
|
||||||
|
Text(subHeader)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var headerView: some View {
|
||||||
|
Text(header)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.padding(.bottom, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentView: some View {
|
||||||
|
Text(content)
|
||||||
|
.font(.caption.weight(.light))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.backport
|
||||||
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
onSelect()
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
subHeaderView
|
||||||
|
|
||||||
|
headerView
|
||||||
|
|
||||||
|
contentView
|
||||||
|
|
||||||
|
L10n.seeMore.text
|
||||||
|
.font(.caption.weight(.light))
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
}
|
||||||
|
.padding(5)
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector.EpisodeContent {
|
||||||
|
|
||||||
|
init(
|
||||||
|
subHeader: String,
|
||||||
|
header: String,
|
||||||
|
content: String
|
||||||
|
) {
|
||||||
|
self.subHeader = subHeader
|
||||||
|
self.header = header
|
||||||
|
self.content = content
|
||||||
|
self.onSelect = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onSelect(perform action: @escaping () -> Void) -> Self {
|
||||||
|
copy(modifying: \.onSelect, with: action)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
//
|
||||||
|
// 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 CollectionHStack
|
||||||
|
import Foundation
|
||||||
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct EpisodeHStack: View {
|
||||||
|
|
||||||
|
@EnvironmentObject
|
||||||
|
private var focusGuide: FocusGuide
|
||||||
|
|
||||||
|
@FocusState
|
||||||
|
private var focusedEpisodeID: String?
|
||||||
|
|
||||||
|
@ObservedObject
|
||||||
|
var viewModel: SeasonItemViewModel
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var didScrollToPlayButtonItem = false
|
||||||
|
@State
|
||||||
|
private var lastFocusedEpisodeID: String?
|
||||||
|
|
||||||
|
@StateObject
|
||||||
|
private var proxy = CollectionHStackProxy<BaseItemDto>()
|
||||||
|
|
||||||
|
let playButtonItem: BaseItemDto?
|
||||||
|
|
||||||
|
private func contentView(viewModel: SeasonItemViewModel) -> some View {
|
||||||
|
CollectionHStack(
|
||||||
|
$viewModel.elements,
|
||||||
|
columns: 3.5
|
||||||
|
) { episode in
|
||||||
|
SeriesEpisodeSelector.EpisodeCard(episode: episode)
|
||||||
|
.focused($focusedEpisodeID, equals: episode.id)
|
||||||
|
}
|
||||||
|
.scrollBehavior(.continuousLeadingEdge)
|
||||||
|
.insets(horizontal: EdgeInsets.defaultEdgePadding)
|
||||||
|
.itemSpacing(EdgeInsets.defaultEdgePadding / 2)
|
||||||
|
.proxy(proxy)
|
||||||
|
.onFirstAppear {
|
||||||
|
guard !didScrollToPlayButtonItem else { return }
|
||||||
|
didScrollToPlayButtonItem = true
|
||||||
|
|
||||||
|
lastFocusedEpisodeID = playButtonItem?.id
|
||||||
|
|
||||||
|
// good enough?
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
guard let playButtonItem else { return }
|
||||||
|
proxy.scrollTo(element: playButtonItem, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
WrappedView {
|
||||||
|
switch viewModel.state {
|
||||||
|
case .content:
|
||||||
|
contentView(viewModel: viewModel)
|
||||||
|
case let .error(error):
|
||||||
|
ErrorHStack(viewModel: viewModel, error: error)
|
||||||
|
case .initial, .refreshing:
|
||||||
|
LoadingHStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusSection()
|
||||||
|
.focusGuide(
|
||||||
|
focusGuide,
|
||||||
|
tag: "episodes",
|
||||||
|
onContentFocus: { focusedEpisodeID = lastFocusedEpisodeID },
|
||||||
|
top: "seasons"
|
||||||
|
)
|
||||||
|
.onChange(of: viewModel) { newValue in
|
||||||
|
lastFocusedEpisodeID = newValue.elements.first?.id
|
||||||
|
}
|
||||||
|
.onChange(of: focusedEpisodeID) { newValue in
|
||||||
|
guard let newValue else { return }
|
||||||
|
lastFocusedEpisodeID = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorHStack: View {
|
||||||
|
|
||||||
|
@ObservedObject
|
||||||
|
var viewModel: SeasonItemViewModel
|
||||||
|
|
||||||
|
let error: JellyfinAPIError
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CollectionHStack(
|
||||||
|
0 ..< 1,
|
||||||
|
columns: 3.5
|
||||||
|
) { _ in
|
||||||
|
SeriesEpisodeSelector.ErrorCard(error: error)
|
||||||
|
.onSelect {
|
||||||
|
viewModel.send(.refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.allowScrolling(false)
|
||||||
|
.insets(horizontal: EdgeInsets.defaultEdgePadding)
|
||||||
|
.itemSpacing(EdgeInsets.defaultEdgePadding / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoadingHStack: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CollectionHStack(
|
||||||
|
0 ..< Int.random(in: 2 ..< 5),
|
||||||
|
columns: 3.5
|
||||||
|
) { _ in
|
||||||
|
SeriesEpisodeSelector.LoadingCard()
|
||||||
|
}
|
||||||
|
.allowScrolling(false)
|
||||||
|
.insets(horizontal: EdgeInsets.defaultEdgePadding)
|
||||||
|
.itemSpacing(EdgeInsets.defaultEdgePadding / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
//
|
||||||
|
// 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 SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct ErrorCard: View {
|
||||||
|
|
||||||
|
let error: JellyfinAPIError
|
||||||
|
private var onSelect: () -> Void
|
||||||
|
|
||||||
|
init(error: JellyfinAPIError) {
|
||||||
|
self.error = error
|
||||||
|
self.onSelect = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onSelect(perform action: @escaping () -> Void) -> Self {
|
||||||
|
copy(modifying: \.onSelect, with: action)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
onSelect()
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Color.secondarySystemFill
|
||||||
|
.opacity(0.75)
|
||||||
|
.posterStyle(.landscape)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "arrow.clockwise.circle.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
}
|
||||||
|
|
||||||
|
SeriesEpisodeSelector.EpisodeContent(
|
||||||
|
subHeader: .emptyDash,
|
||||||
|
header: L10n.error,
|
||||||
|
content: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// Swiftfin is subject to the terms of the Mozilla Public
|
||||||
|
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct LoadingCard: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Color.secondarySystemFill
|
||||||
|
.opacity(0.75)
|
||||||
|
.posterStyle(.landscape)
|
||||||
|
|
||||||
|
SeriesEpisodeSelector.EpisodeContent(
|
||||||
|
subHeader: String.random(count: 7 ..< 12),
|
||||||
|
header: String.random(count: 10 ..< 20),
|
||||||
|
content: String.random(count: 20 ..< 80)
|
||||||
|
)
|
||||||
|
.redacted(reason: .placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,13 +19,40 @@ struct SeriesEpisodeSelector: View {
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
private var parentFocusGuide: FocusGuide
|
private var parentFocusGuide: FocusGuide
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var didSelectPlayButtonSeason = false
|
||||||
|
@State
|
||||||
|
private var selection: SeasonItemViewModel?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
SeasonsHStack(viewModel: viewModel)
|
SeasonsHStack(viewModel: viewModel, selection: $selection)
|
||||||
.environmentObject(parentFocusGuide)
|
.environmentObject(parentFocusGuide)
|
||||||
|
|
||||||
EpisodesHStack(viewModel: viewModel)
|
if let selection {
|
||||||
.environmentObject(parentFocusGuide)
|
EpisodeHStack(viewModel: selection, playButtonItem: viewModel.playButtonItem)
|
||||||
|
.environmentObject(parentFocusGuide)
|
||||||
|
} else {
|
||||||
|
LoadingHStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(viewModel.playButtonItem.publisher) { newValue in
|
||||||
|
|
||||||
|
guard !didSelectPlayButtonSeason else { return }
|
||||||
|
didSelectPlayButtonSeason = true
|
||||||
|
|
||||||
|
if let season = viewModel.seasons.first(where: { $0.season.id == newValue.seasonID }) {
|
||||||
|
selection = season
|
||||||
|
} else {
|
||||||
|
selection = viewModel.seasons.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: selection) { newValue in
|
||||||
|
guard let newValue else { return }
|
||||||
|
|
||||||
|
if newValue.state == .initial {
|
||||||
|
newValue.send(.refresh)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,39 +63,41 @@ extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
struct SeasonsHStack: View {
|
struct SeasonsHStack: View {
|
||||||
|
|
||||||
@ObservedObject
|
|
||||||
var viewModel: SeriesItemViewModel
|
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
private var focusGuide: FocusGuide
|
private var focusGuide: FocusGuide
|
||||||
|
|
||||||
@FocusState
|
@FocusState
|
||||||
private var focusedSeason: BaseItemDto?
|
private var focusedSeason: SeasonItemViewModel?
|
||||||
|
|
||||||
|
@ObservedObject
|
||||||
|
var viewModel: SeriesItemViewModel
|
||||||
|
|
||||||
|
var selection: Binding<SeasonItemViewModel?>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack {
|
HStack {
|
||||||
ForEach(viewModel.menuSections.keys.sorted(by: { viewModel.menuSectionSort($0, $1) }), id: \.self) { season in
|
ForEach(viewModel.seasons, id: \.season.id) { seasonViewModel in
|
||||||
Button {
|
Button {
|
||||||
Text(season.displayTitle)
|
Text(seasonViewModel.season.displayTitle)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.if(viewModel.menuSelection == season) { text in
|
.if(selection.wrappedValue == seasonViewModel) { text in
|
||||||
text
|
text
|
||||||
.background(Color.white)
|
.background(Color.white)
|
||||||
.foregroundColor(.black)
|
.foregroundColor(.black)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.card)
|
.buttonStyle(.card)
|
||||||
.focused($focusedSeason, equals: season)
|
.focused($focusedSeason, equals: seasonViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.focusGuide(
|
.focusGuide(
|
||||||
focusGuide,
|
focusGuide,
|
||||||
tag: "seasons",
|
tag: "seasons",
|
||||||
onContentFocus: { focusedSeason = viewModel.menuSelection },
|
onContentFocus: { focusedSeason = selection.wrappedValue },
|
||||||
top: "top",
|
top: "top",
|
||||||
bottom: "episodes"
|
bottom: "episodes"
|
||||||
)
|
)
|
||||||
|
@ -77,41 +106,6 @@ extension SeriesEpisodeSelector {
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
.padding(.bottom, 45)
|
.padding(.bottom, 45)
|
||||||
}
|
}
|
||||||
.onChange(of: focusedSeason) { season in
|
|
||||||
guard let season = season else { return }
|
|
||||||
viewModel.select(section: season)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SeriesEpisodeSelector {
|
|
||||||
|
|
||||||
// MARK: EpisodesHStack
|
|
||||||
|
|
||||||
struct EpisodesHStack: View {
|
|
||||||
|
|
||||||
@ObservedObject
|
|
||||||
var viewModel: SeriesItemViewModel
|
|
||||||
|
|
||||||
@EnvironmentObject
|
|
||||||
private var focusGuide: FocusGuide
|
|
||||||
@FocusState
|
|
||||||
private var focusedEpisodeID: String?
|
|
||||||
@State
|
|
||||||
private var lastFocusedEpisodeID: String?
|
|
||||||
@State
|
|
||||||
private var wrappedScrollView: UIScrollView?
|
|
||||||
|
|
||||||
var contentView: some View {
|
|
||||||
CollectionHStack(
|
|
||||||
$viewModel.currentItems,
|
|
||||||
columns: 3.5
|
|
||||||
) { item in
|
|
||||||
EpisodeCard(episode: item)
|
|
||||||
.focused($focusedEpisodeID, equals: item.id)
|
|
||||||
}
|
|
||||||
.insets(vertical: 20)
|
|
||||||
.mask {
|
.mask {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Color.white
|
Color.white
|
||||||
|
@ -127,31 +121,9 @@ extension SeriesEpisodeSelector {
|
||||||
.frame(height: 20)
|
.frame(height: 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.onChange(of: focusedSeason) { newValue in
|
||||||
.focusSection()
|
guard let newValue else { return }
|
||||||
.focusGuide(
|
selection.wrappedValue = newValue
|
||||||
focusGuide,
|
|
||||||
tag: "episodes",
|
|
||||||
onContentFocus: { focusedEpisodeID = lastFocusedEpisodeID },
|
|
||||||
top: "seasons"
|
|
||||||
)
|
|
||||||
.onChange(of: viewModel.menuSelection) { _ in
|
|
||||||
lastFocusedEpisodeID = viewModel.currentItems.first?.id
|
|
||||||
}
|
|
||||||
.onChange(of: focusedEpisodeID) { episodeIndex in
|
|
||||||
guard let episodeIndex = episodeIndex else { return }
|
|
||||||
lastFocusedEpisodeID = episodeIndex
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.currentItems) { _ in
|
|
||||||
lastFocusedEpisodeID = viewModel.currentItems.first?.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if viewModel.currentItems.isEmpty {
|
|
||||||
EmptyView()
|
|
||||||
} else {
|
|
||||||
contentView
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -25,6 +25,14 @@ extension ItemView {
|
||||||
@FocusState
|
@FocusState
|
||||||
private var isFocused: Bool
|
private var isFocused: Bool
|
||||||
|
|
||||||
|
private var title: String {
|
||||||
|
if let seriesViewModel = viewModel as? SeriesItemViewModel {
|
||||||
|
return seriesViewModel.playButtonItem?.seasonEpisodeLabel ?? L10n.play
|
||||||
|
} else {
|
||||||
|
return viewModel.playButtonItem?.playButtonLabel ?? L10n.play
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource {
|
if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource {
|
||||||
|
@ -37,7 +45,8 @@ extension ItemView {
|
||||||
Image(systemName: "play.fill")
|
Image(systemName: "play.fill")
|
||||||
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black)
|
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
Text(viewModel.playButtonText())
|
|
||||||
|
Text(title)
|
||||||
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black)
|
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,24 +13,59 @@ import SwiftUI
|
||||||
|
|
||||||
struct ItemView: View {
|
struct ItemView: View {
|
||||||
|
|
||||||
private let item: BaseItemDto
|
@StateObject
|
||||||
|
private var viewModel: ItemViewModel
|
||||||
|
|
||||||
|
private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel {
|
||||||
|
switch item.type {
|
||||||
|
case .boxSet:
|
||||||
|
return CollectionItemViewModel(item: item)
|
||||||
|
case .episode:
|
||||||
|
return EpisodeItemViewModel(item: item)
|
||||||
|
case .movie:
|
||||||
|
return MovieItemViewModel(item: item)
|
||||||
|
case .series:
|
||||||
|
return SeriesItemViewModel(item: item)
|
||||||
|
default:
|
||||||
|
assertionFailure("Unsupported item")
|
||||||
|
return ItemViewModel(item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init(item: BaseItemDto) {
|
init(item: BaseItemDto) {
|
||||||
self.item = item
|
self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentView: some View {
|
||||||
|
switch viewModel.item.type {
|
||||||
|
case .boxSet:
|
||||||
|
CollectionItemView(viewModel: viewModel as! CollectionItemViewModel)
|
||||||
|
case .episode:
|
||||||
|
EpisodeItemView(viewModel: viewModel as! EpisodeItemViewModel)
|
||||||
|
case .movie:
|
||||||
|
MovieItemView(viewModel: viewModel as! MovieItemViewModel)
|
||||||
|
case .series:
|
||||||
|
SeriesItemView(viewModel: viewModel as! SeriesItemViewModel)
|
||||||
|
default:
|
||||||
|
Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch item.type {
|
WrappedView {
|
||||||
case .movie:
|
switch viewModel.state {
|
||||||
MovieItemView(viewModel: .init(item: item))
|
case .content:
|
||||||
case .episode:
|
contentView
|
||||||
EpisodeItemView(viewModel: .init(item: item))
|
case let .error(error):
|
||||||
case .series:
|
Text(error.localizedDescription)
|
||||||
SeriesItemView(viewModel: .init(item: item))
|
case .initial, .refreshing:
|
||||||
case .boxSet:
|
ProgressView()
|
||||||
CollectionItemView(viewModel: .init(item: item))
|
}
|
||||||
default:
|
}
|
||||||
Text(L10n.notImplementedYetWithType(item.type ?? "--"))
|
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||||
|
.onFirstAppear {
|
||||||
|
viewModel.send(.refresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,97 +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 Factory
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// TODO: Should episodes also respect some indicator settings?
|
|
||||||
|
|
||||||
struct EpisodeCard: View {
|
|
||||||
|
|
||||||
@Injected(LogManager.service)
|
|
||||||
private var logger
|
|
||||||
|
|
||||||
@EnvironmentObject
|
|
||||||
private var router: ItemCoordinator.Router
|
|
||||||
|
|
||||||
let episode: BaseItemDto
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
PosterButton(
|
|
||||||
item: episode,
|
|
||||||
type: .landscape,
|
|
||||||
singleImage: true
|
|
||||||
)
|
|
||||||
.content {
|
|
||||||
Button {
|
|
||||||
router.route(to: \.item, episode)
|
|
||||||
} label: {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
Color.clear
|
|
||||||
.frame(height: 0.01)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
Text(episode.episodeLocator ?? L10n.unknown)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(episode.displayTitle)
|
|
||||||
.font(.footnote)
|
|
||||||
.padding(.bottom, 1)
|
|
||||||
|
|
||||||
if episode.isUnaired {
|
|
||||||
Text(episode.airDateLabel ?? L10n.noOverviewAvailable)
|
|
||||||
.font(.caption)
|
|
||||||
.lineLimit(1)
|
|
||||||
} else {
|
|
||||||
Text(episode.overview ?? L10n.noOverviewAvailable)
|
|
||||||
.font(.caption)
|
|
||||||
.lineLimit(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
|
||||||
L10n.seeMore.text
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(.jellyfinPurple)
|
|
||||||
}
|
|
||||||
.aspectRatio(510 / 220, contentMode: .fill)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.buttonStyle(.card)
|
|
||||||
}
|
|
||||||
.imageOverlay {
|
|
||||||
ZStack {
|
|
||||||
if episode.userData?.isPlayed ?? false {
|
|
||||||
WatchedIndicator(size: 45)
|
|
||||||
} else {
|
|
||||||
if (episode.userData?.playbackPositionTicks ?? 0) > 0 {
|
|
||||||
LandscapePosterProgressBar(
|
|
||||||
title: episode.progressLabel ?? L10n.continue,
|
|
||||||
progress: (episode.userData?.playedPercentage ?? 0) / 100
|
|
||||||
)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onSelect {
|
|
||||||
guard let mediaSource = episode.mediaSources?.first else {
|
|
||||||
logger.error("No media source attached to episode", metadata: ["episode title": .string(episode.displayTitle)])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: episode, mediaSource: mediaSource))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -28,8 +28,10 @@ extension SeriesItemView {
|
||||||
.frame(height: UIScreen.main.bounds.height - 150)
|
.frame(height: UIScreen.main.bounds.height - 150)
|
||||||
.padding(.bottom, 50)
|
.padding(.bottom, 50)
|
||||||
|
|
||||||
SeriesEpisodeSelector(viewModel: viewModel)
|
if viewModel.seasons.isNotEmpty {
|
||||||
.environmentObject(focusGuide)
|
SeriesEpisodeSelector(viewModel: viewModel)
|
||||||
|
.environmentObject(focusGuide)
|
||||||
|
}
|
||||||
|
|
||||||
if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty {
|
if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty {
|
||||||
ItemView.CastAndCrewHStack(people: castAndCrew)
|
ItemView.CastAndCrewHStack(people: castAndCrew)
|
||||||
|
|
|
@ -74,7 +74,7 @@ struct SearchView: View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func itemsSection(
|
private func itemsSection(
|
||||||
title: String,
|
title: String,
|
||||||
keyPath: ReferenceWritableKeyPath<SearchViewModel, [BaseItemDto]>,
|
keyPath: KeyPath<SearchViewModel, [BaseItemDto]>,
|
||||||
posterType: PosterType
|
posterType: PosterType
|
||||||
) -> some View {
|
) -> some View {
|
||||||
PosterHStack(
|
PosterHStack(
|
||||||
|
|
|
@ -178,8 +178,6 @@
|
||||||
E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; };
|
E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; };
|
||||||
E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */; };
|
E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */; };
|
||||||
E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */; };
|
E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */; };
|
||||||
E104DC902B9D8995008F506D /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E104DC8F2B9D8995008F506D /* CollectionVGrid */; };
|
|
||||||
E104DC942B9D89A2008F506D /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E104DC932B9D89A2008F506D /* CollectionVGrid */; };
|
|
||||||
E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104DC952B9E7E29008F506D /* AssertionFailureView.swift */; };
|
E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104DC952B9E7E29008F506D /* AssertionFailureView.swift */; };
|
||||||
E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104DC952B9E7E29008F506D /* AssertionFailureView.swift */; };
|
E104DC972B9E7E29008F506D /* AssertionFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104DC952B9E7E29008F506D /* AssertionFailureView.swift */; };
|
||||||
E10706102942F57D00646DAF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E107060F2942F57D00646DAF /* Pulse */; };
|
E10706102942F57D00646DAF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E107060F2942F57D00646DAF /* Pulse */; };
|
||||||
|
@ -212,6 +210,17 @@
|
||||||
E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A62B5A178D009CAAAA /* CollectionHStack */; };
|
E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A62B5A178D009CAAAA /* CollectionHStack */; };
|
||||||
E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A92B5A179A009CAAAA /* CollectionVGrid */; };
|
E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E113A2A92B5A179A009CAAAA /* CollectionVGrid */; };
|
||||||
E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E114DB322B1944FA00B75FB3 /* CollectionVGrid */; };
|
E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E114DB322B1944FA00B75FB3 /* CollectionVGrid */; };
|
||||||
|
E1153D942BBA3D3000424D36 /* EpisodeContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153D932BBA3D3000424D36 /* EpisodeContent.swift */; };
|
||||||
|
E1153D962BBA3E2F00424D36 /* EpisodeHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */; };
|
||||||
|
E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153D992BBA3E9800424D36 /* ErrorCard.swift */; };
|
||||||
|
E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153D9B2BBA3E9D00424D36 /* LoadingCard.swift */; };
|
||||||
|
E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DA32BBA614F00424D36 /* CollectionVGrid */; };
|
||||||
|
E1153DA72BBA641000424D36 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DA62BBA641000424D36 /* CollectionVGrid */; };
|
||||||
|
E1153DA92BBA642A00424D36 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DA82BBA642A00424D36 /* CollectionVGrid */; };
|
||||||
|
E1153DAC2BBA6AD200424D36 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DAB2BBA6AD200424D36 /* CollectionHStack */; };
|
||||||
|
E1153DAF2BBA734200424D36 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DAE2BBA734200424D36 /* CollectionHStack */; };
|
||||||
|
E1153DB12BBA734C00424D36 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DB02BBA734C00424D36 /* CollectionHStack */; };
|
||||||
|
E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153DB22BBA80B400424D36 /* EmptyCard.swift */; };
|
||||||
E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; };
|
E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; };
|
||||||
E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; };
|
E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; };
|
||||||
E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; };
|
E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; };
|
||||||
|
@ -285,8 +294,6 @@
|
||||||
E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1388A41293F0AAD009721B1 /* PreferenceUIHostingController.swift */; };
|
E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1388A41293F0AAD009721B1 /* PreferenceUIHostingController.swift */; };
|
||||||
E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E1388A45293F0ABA009721B1 /* SwizzleSwift */; };
|
E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E1388A45293F0ABA009721B1 /* SwizzleSwift */; };
|
||||||
E1392FED2BA218A80034110D /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = E1392FEC2BA218A80034110D /* SwiftUIIntrospect */; };
|
E1392FED2BA218A80034110D /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = E1392FEC2BA218A80034110D /* SwiftUIIntrospect */; };
|
||||||
E1392FF22BA21B360034110D /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1392FF12BA21B360034110D /* CollectionHStack */; };
|
|
||||||
E1392FF42BA21B470034110D /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1392FF32BA21B470034110D /* CollectionHStack */; };
|
|
||||||
E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E139CC1C28EC836F00688DE2 /* ChapterOverlay.swift */; };
|
E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E139CC1C28EC836F00688DE2 /* ChapterOverlay.swift */; };
|
||||||
E139CC1F28EC83E400688DE2 /* Int.swift in Sources */ = {isa = PBXBuildFile; fileRef = E139CC1E28EC83E400688DE2 /* Int.swift */; };
|
E139CC1F28EC83E400688DE2 /* Int.swift in Sources */ = {isa = PBXBuildFile; fileRef = E139CC1E28EC83E400688DE2 /* Int.swift */; };
|
||||||
E13AF3B628A0C598009093AB /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B528A0C598009093AB /* Nuke */; };
|
E13AF3B628A0C598009093AB /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B528A0C598009093AB /* Nuke */; };
|
||||||
|
@ -446,6 +453,9 @@
|
||||||
E1721FAE28FB801C00762992 /* SmallPlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1721FAD28FB801C00762992 /* SmallPlaybackButtons.swift */; };
|
E1721FAE28FB801C00762992 /* SmallPlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1721FAD28FB801C00762992 /* SmallPlaybackButtons.swift */; };
|
||||||
E1722DB129491C3900CC0239 /* ImageBlurHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */; };
|
E1722DB129491C3900CC0239 /* ImageBlurHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */; };
|
||||||
E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */; };
|
E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */; };
|
||||||
|
E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */; };
|
||||||
|
E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */; };
|
||||||
|
E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E172D3B12BACA569007B4647 /* EpisodeContent.swift */; };
|
||||||
E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; };
|
E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; };
|
||||||
E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* Color.swift */; };
|
E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* Color.swift */; };
|
||||||
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; };
|
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; };
|
||||||
|
@ -496,6 +506,7 @@
|
||||||
E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDto.swift */; };
|
E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B128A229E70092E7F1 /* UserDto.swift */; };
|
||||||
E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; };
|
E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; };
|
||||||
E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */; };
|
E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */; };
|
||||||
|
E18D6AA62BAA96F000A0D167 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E18D6AA52BAA96F000A0D167 /* CollectionHStack */; };
|
||||||
E18E01AB288746AF0022598C /* PillHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A5288746AF0022598C /* PillHStack.swift */; };
|
E18E01AB288746AF0022598C /* PillHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A5288746AF0022598C /* PillHStack.swift */; };
|
||||||
E18E01AD288746AF0022598C /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A7288746AF0022598C /* DotHStack.swift */; };
|
E18E01AD288746AF0022598C /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A7288746AF0022598C /* DotHStack.swift */; };
|
||||||
E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01B6288747230022598C /* iPadOSEpisodeContentView.swift */; };
|
E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01B6288747230022598C /* iPadOSEpisodeContentView.swift */; };
|
||||||
|
@ -574,6 +585,12 @@
|
||||||
E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */; };
|
E1A1529128FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */; };
|
||||||
E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A16C9C2889AF1E00EA4679 /* AboutView.swift */; };
|
E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A16C9C2889AF1E00EA4679 /* AboutView.swift */; };
|
||||||
E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplication.swift */; };
|
E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C153279A7D5A005EC829 /* UIApplication.swift */; };
|
||||||
|
E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4C62BB74E50005C59F8 /* EpisodeCard.swift */; };
|
||||||
|
E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4C82BB74EA3005C59F8 /* LoadingCard.swift */; };
|
||||||
|
E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4CA2BB74EFD005C59F8 /* EpisodeHStack.swift */; };
|
||||||
|
E1A3E4CD2BB7D8C8005C59F8 /* iOSLabelExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4CC2BB7D8C8005C59F8 /* iOSLabelExtensions.swift */; };
|
||||||
|
E1A3E4CF2BB7E02B005C59F8 /* DelayedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */; };
|
||||||
|
E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */; };
|
||||||
E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */; };
|
E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */; };
|
||||||
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */; };
|
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */; };
|
||||||
E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */; };
|
E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */; };
|
||||||
|
@ -626,7 +643,7 @@
|
||||||
E1C926102887565C002A7A66 /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926022887565C002A7A66 /* PlayButton.swift */; };
|
E1C926102887565C002A7A66 /* PlayButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926022887565C002A7A66 /* PlayButton.swift */; };
|
||||||
E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926032887565C002A7A66 /* ActionButtonHStack.swift */; };
|
E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926032887565C002A7A66 /* ActionButtonHStack.swift */; };
|
||||||
E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926052887565C002A7A66 /* SeriesItemContentView.swift */; };
|
E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926052887565C002A7A66 /* SeriesItemContentView.swift */; };
|
||||||
E1C926132887565C002A7A66 /* SeriesEpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926072887565C002A7A66 /* SeriesEpisodeSelector.swift */; };
|
E1C926132887565C002A7A66 /* EpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926072887565C002A7A66 /* EpisodeSelector.swift */; };
|
||||||
E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926092887565C002A7A66 /* EpisodeCard.swift */; };
|
E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926092887565C002A7A66 /* EpisodeCard.swift */; };
|
||||||
E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9260A2887565C002A7A66 /* SeriesItemView.swift */; };
|
E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9260A2887565C002A7A66 /* SeriesItemView.swift */; };
|
||||||
E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92617288756BD002A7A66 /* PosterButton.swift */; };
|
E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92617288756BD002A7A66 /* PosterButton.swift */; };
|
||||||
|
@ -674,7 +691,7 @@
|
||||||
E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8429429346C6400D1041A /* BasicStepper.swift */; };
|
E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8429429346C6400D1041A /* BasicStepper.swift */; };
|
||||||
E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */; };
|
E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */; };
|
||||||
E1DA654C28E69B0500592A73 /* SpecialFeatureType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */; };
|
E1DA654C28E69B0500592A73 /* SpecialFeatureType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */; };
|
||||||
E1DA656F28E78C9900592A73 /* SeriesEpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */; };
|
E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DA656E28E78C9900592A73 /* EpisodeSelector.swift */; };
|
||||||
E1DABAFA2A270E62008AC34A /* OverviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAF92A270E62008AC34A /* OverviewCard.swift */; };
|
E1DABAFA2A270E62008AC34A /* OverviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAF92A270E62008AC34A /* OverviewCard.swift */; };
|
||||||
E1DABAFC2A270EE7008AC34A /* MediaSourcesCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAFB2A270EE7008AC34A /* MediaSourcesCard.swift */; };
|
E1DABAFC2A270EE7008AC34A /* MediaSourcesCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAFB2A270EE7008AC34A /* MediaSourcesCard.swift */; };
|
||||||
E1DABAFE2A27B982008AC34A /* RatingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAFD2A27B982008AC34A /* RatingsCard.swift */; };
|
E1DABAFE2A27B982008AC34A /* RatingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DABAFD2A27B982008AC34A /* RatingsCard.swift */; };
|
||||||
|
@ -951,6 +968,11 @@
|
||||||
E113133528BE98AA00930F75 /* FilterDrawerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDrawerButton.swift; sourceTree = "<group>"; };
|
E113133528BE98AA00930F75 /* FilterDrawerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterDrawerButton.swift; sourceTree = "<group>"; };
|
||||||
E113133728BEADBA00930F75 /* LibraryParent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryParent.swift; sourceTree = "<group>"; };
|
E113133728BEADBA00930F75 /* LibraryParent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryParent.swift; sourceTree = "<group>"; };
|
||||||
E113133928BEB71D00930F75 /* FilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterViewModel.swift; sourceTree = "<group>"; };
|
E113133928BEB71D00930F75 /* FilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
E1153D932BBA3D3000424D36 /* EpisodeContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeContent.swift; sourceTree = "<group>"; };
|
||||||
|
E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeHStack.swift; sourceTree = "<group>"; };
|
||||||
|
E1153D992BBA3E9800424D36 /* ErrorCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorCard.swift; sourceTree = "<group>"; };
|
||||||
|
E1153D9B2BBA3E9D00424D36 /* LoadingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCard.swift; sourceTree = "<group>"; };
|
||||||
|
E1153DB22BBA80B400424D36 /* EmptyCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyCard.swift; sourceTree = "<group>"; };
|
||||||
E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = "<group>"; };
|
E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = "<group>"; };
|
||||||
E118959C289312020042947B /* BaseItemPerson+Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemPerson+Poster.swift"; sourceTree = "<group>"; };
|
E118959C289312020042947B /* BaseItemPerson+Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemPerson+Poster.swift"; sourceTree = "<group>"; };
|
||||||
E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewOffsetModifier.swift; sourceTree = "<group>"; };
|
E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewOffsetModifier.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1069,6 +1091,8 @@
|
||||||
E1721FA928FB7CAC00762992 /* CompactTimeStamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactTimeStamp.swift; sourceTree = "<group>"; };
|
E1721FA928FB7CAC00762992 /* CompactTimeStamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactTimeStamp.swift; sourceTree = "<group>"; };
|
||||||
E1721FAD28FB801C00762992 /* SmallPlaybackButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallPlaybackButtons.swift; sourceTree = "<group>"; };
|
E1721FAD28FB801C00762992 /* SmallPlaybackButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallPlaybackButtons.swift; sourceTree = "<group>"; };
|
||||||
E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBlurHashes.swift; sourceTree = "<group>"; };
|
E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBlurHashes.swift; sourceTree = "<group>"; };
|
||||||
|
E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
E172D3B12BACA569007B4647 /* EpisodeContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeContent.swift; sourceTree = "<group>"; };
|
||||||
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = "<group>"; };
|
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = "<group>"; };
|
||||||
E173DA5126D04AAF00CC4EB7 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
|
E173DA5126D04AAF00CC4EB7 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
|
||||||
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = "<group>"; };
|
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1155,6 +1179,12 @@
|
||||||
E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSettingsCoordinator.swift; sourceTree = "<group>"; };
|
E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSettingsCoordinator.swift; sourceTree = "<group>"; };
|
||||||
E1A16C9C2889AF1E00EA4679 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
E1A16C9C2889AF1E00EA4679 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
||||||
E1A2C153279A7D5A005EC829 /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
|
E1A2C153279A7D5A005EC829 /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
|
||||||
|
E1A3E4C62BB74E50005C59F8 /* EpisodeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = "<group>"; };
|
||||||
|
E1A3E4C82BB74EA3005C59F8 /* LoadingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCard.swift; sourceTree = "<group>"; };
|
||||||
|
E1A3E4CA2BB74EFD005C59F8 /* EpisodeHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeHStack.swift; sourceTree = "<group>"; };
|
||||||
|
E1A3E4CC2BB7D8C8005C59F8 /* iOSLabelExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSLabelExtensions.swift; sourceTree = "<group>"; };
|
||||||
|
E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayedProgressView.swift; sourceTree = "<group>"; };
|
||||||
|
E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorCard.swift; sourceTree = "<group>"; };
|
||||||
E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = "<group>"; };
|
E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = "<group>"; };
|
||||||
E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = "<group>"; };
|
E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = "<group>"; };
|
||||||
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = "<group>"; };
|
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1196,7 +1226,7 @@
|
||||||
E1C926022887565C002A7A66 /* PlayButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = "<group>"; };
|
E1C926022887565C002A7A66 /* PlayButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayButton.swift; sourceTree = "<group>"; };
|
||||||
E1C926032887565C002A7A66 /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = "<group>"; };
|
E1C926032887565C002A7A66 /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = "<group>"; };
|
||||||
E1C926052887565C002A7A66 /* SeriesItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemContentView.swift; sourceTree = "<group>"; };
|
E1C926052887565C002A7A66 /* SeriesItemContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemContentView.swift; sourceTree = "<group>"; };
|
||||||
E1C926072887565C002A7A66 /* SeriesEpisodeSelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesEpisodeSelector.swift; sourceTree = "<group>"; };
|
E1C926072887565C002A7A66 /* EpisodeSelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeSelector.swift; sourceTree = "<group>"; };
|
||||||
E1C926092887565C002A7A66 /* EpisodeCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = "<group>"; };
|
E1C926092887565C002A7A66 /* EpisodeCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = "<group>"; };
|
||||||
E1C9260A2887565C002A7A66 /* SeriesItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = "<group>"; };
|
E1C9260A2887565C002A7A66 /* SeriesItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = "<group>"; };
|
||||||
E1C92617288756BD002A7A66 /* PosterButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = "<group>"; };
|
E1C92617288756BD002A7A66 /* PosterButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1234,7 +1264,7 @@
|
||||||
E1D8429429346C6400D1041A /* BasicStepper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicStepper.swift; sourceTree = "<group>"; };
|
E1D8429429346C6400D1041A /* BasicStepper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicStepper.swift; sourceTree = "<group>"; };
|
||||||
E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayer.swift; sourceTree = "<group>"; };
|
E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayer.swift; sourceTree = "<group>"; };
|
||||||
E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeatureType.swift; sourceTree = "<group>"; };
|
E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeatureType.swift; sourceTree = "<group>"; };
|
||||||
E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesEpisodeSelector.swift; sourceTree = "<group>"; };
|
E1DA656E28E78C9900592A73 /* EpisodeSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeSelector.swift; sourceTree = "<group>"; };
|
||||||
E1DABAF92A270E62008AC34A /* OverviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewCard.swift; sourceTree = "<group>"; };
|
E1DABAF92A270E62008AC34A /* OverviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewCard.swift; sourceTree = "<group>"; };
|
||||||
E1DABAFB2A270EE7008AC34A /* MediaSourcesCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourcesCard.swift; sourceTree = "<group>"; };
|
E1DABAFB2A270EE7008AC34A /* MediaSourcesCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourcesCard.swift; sourceTree = "<group>"; };
|
||||||
E1DABAFD2A27B982008AC34A /* RatingsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsCard.swift; sourceTree = "<group>"; };
|
E1DABAFD2A27B982008AC34A /* RatingsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingsCard.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1330,10 +1360,10 @@
|
||||||
62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */,
|
62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */,
|
||||||
E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */,
|
E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */,
|
||||||
E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */,
|
E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */,
|
||||||
E104DC942B9D89A2008F506D /* CollectionVGrid in Frameworks */,
|
E1153DA92BBA642A00424D36 /* CollectionVGrid in Frameworks */,
|
||||||
62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */,
|
62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */,
|
||||||
E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */,
|
E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */,
|
||||||
E1392FF42BA21B470034110D /* CollectionHStack in Frameworks */,
|
E1153DB12BBA734C00424D36 /* CollectionHStack in Frameworks */,
|
||||||
62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */,
|
62666E1327E501C300EC0ECD /* AudioToolbox.framework in Frameworks */,
|
||||||
E13AF3B628A0C598009093AB /* Nuke in Frameworks */,
|
E13AF3B628A0C598009093AB /* Nuke in Frameworks */,
|
||||||
E12186DE2718F1C50010884C /* Defaults in Frameworks */,
|
E12186DE2718F1C50010884C /* Defaults in Frameworks */,
|
||||||
|
@ -1353,8 +1383,8 @@
|
||||||
E1002B682793CFBA00E47059 /* Algorithms in Frameworks */,
|
E1002B682793CFBA00E47059 /* Algorithms in Frameworks */,
|
||||||
E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */,
|
E113A2AA2B5A179A009CAAAA /* CollectionVGrid in Frameworks */,
|
||||||
62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */,
|
62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */,
|
||||||
E104DC902B9D8995008F506D /* CollectionVGrid in Frameworks */,
|
|
||||||
E15210582946DF1B00375CC2 /* PulseUI in Frameworks */,
|
E15210582946DF1B00375CC2 /* PulseUI in Frameworks */,
|
||||||
|
E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */,
|
||||||
62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */,
|
62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */,
|
||||||
62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */,
|
62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */,
|
||||||
E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */,
|
E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */,
|
||||||
|
@ -1372,16 +1402,18 @@
|
||||||
62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */,
|
62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */,
|
||||||
E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */,
|
E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */,
|
||||||
E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */,
|
E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */,
|
||||||
|
E18D6AA62BAA96F000A0D167 /* CollectionHStack in Frameworks */,
|
||||||
62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */,
|
62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */,
|
||||||
E14CB6862A9FF62A001586C6 /* JellyfinAPI in Frameworks */,
|
E14CB6862A9FF62A001586C6 /* JellyfinAPI in Frameworks */,
|
||||||
62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */,
|
62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */,
|
||||||
E18A8E7A28D5FEDF00333B9A /* VLCUI in Frameworks */,
|
E18A8E7A28D5FEDF00333B9A /* VLCUI in Frameworks */,
|
||||||
|
E1153DA72BBA641000424D36 /* CollectionVGrid in Frameworks */,
|
||||||
E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */,
|
E114DB332B1944FA00B75FB3 /* CollectionVGrid in Frameworks */,
|
||||||
53352571265EA0A0006CCA86 /* Introspect in Frameworks */,
|
53352571265EA0A0006CCA86 /* Introspect in Frameworks */,
|
||||||
E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */,
|
E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */,
|
||||||
|
E1153DAF2BBA734200424D36 /* CollectionHStack in Frameworks */,
|
||||||
62666E0427E5017500EC0ECD /* CoreText.framework in Frameworks */,
|
62666E0427E5017500EC0ECD /* CoreText.framework in Frameworks */,
|
||||||
E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */,
|
E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */,
|
||||||
E1392FF22BA21B360034110D /* CollectionHStack in Frameworks */,
|
|
||||||
62666E0E27E501AF00EC0ECD /* Security.framework in Frameworks */,
|
62666E0E27E501AF00EC0ECD /* Security.framework in Frameworks */,
|
||||||
E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */,
|
E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */,
|
||||||
E15EFA842BA167350080E926 /* CollectionHStack in Frameworks */,
|
E15EFA842BA167350080E926 /* CollectionHStack in Frameworks */,
|
||||||
|
@ -1389,6 +1421,7 @@
|
||||||
62666DFE27E5015700EC0ECD /* AVFoundation.framework in Frameworks */,
|
62666DFE27E5015700EC0ECD /* AVFoundation.framework in Frameworks */,
|
||||||
62666DFD27E5014F00EC0ECD /* AudioToolbox.framework in Frameworks */,
|
62666DFD27E5014F00EC0ECD /* AudioToolbox.framework in Frameworks */,
|
||||||
E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */,
|
E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */,
|
||||||
|
E1153DAC2BBA6AD200424D36 /* CollectionHStack in Frameworks */,
|
||||||
62666E0D27E501AA00EC0ECD /* QuartzCore.framework in Frameworks */,
|
62666E0D27E501AA00EC0ECD /* QuartzCore.framework in Frameworks */,
|
||||||
E15D4F052B1B0C3C00442DB8 /* PreferencesView in Frameworks */,
|
E15D4F052B1B0C3C00442DB8 /* PreferencesView in Frameworks */,
|
||||||
E19E6E0728A0B958005C10C8 /* NukeUI in Frameworks */,
|
E19E6E0728A0B958005C10C8 /* NukeUI in Frameworks */,
|
||||||
|
@ -1801,6 +1834,7 @@
|
||||||
E1D8429429346C6400D1041A /* BasicStepper.swift */,
|
E1D8429429346C6400D1041A /* BasicStepper.swift */,
|
||||||
E1A1528728FD229500600579 /* ChevronButton.swift */,
|
E1A1528728FD229500600579 /* ChevronButton.swift */,
|
||||||
E133328C2953AE4B00EE76AB /* CircularProgressView.swift */,
|
E133328C2953AE4B00EE76AB /* CircularProgressView.swift */,
|
||||||
|
E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */,
|
||||||
E18E01A7288746AF0022598C /* DotHStack.swift */,
|
E18E01A7288746AF0022598C /* DotHStack.swift */,
|
||||||
E1DE2B492B97ECB900F6715F /* ErrorView.swift */,
|
E1DE2B492B97ECB900F6715F /* ErrorView.swift */,
|
||||||
E1921B7528E63306003A5238 /* GestureView.swift */,
|
E1921B7528E63306003A5238 /* GestureView.swift */,
|
||||||
|
@ -1935,6 +1969,7 @@
|
||||||
62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */,
|
62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */,
|
||||||
62E632F2267D54030063E547 /* ItemViewModel.swift */,
|
62E632F2267D54030063E547 /* ItemViewModel.swift */,
|
||||||
62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */,
|
62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */,
|
||||||
|
E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */,
|
||||||
62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */,
|
62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = ItemViewModel;
|
path = ItemViewModel;
|
||||||
|
@ -1983,6 +2018,27 @@
|
||||||
path = NavBarDrawerButtons;
|
path = NavBarDrawerButtons;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E1153D972BBA3E5300424D36 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E1C926092887565C002A7A66 /* EpisodeCard.swift */,
|
||||||
|
E1153D932BBA3D3000424D36 /* EpisodeContent.swift */,
|
||||||
|
E1153D952BBA3E2F00424D36 /* EpisodeHStack.swift */,
|
||||||
|
E1153D992BBA3E9800424D36 /* ErrorCard.swift */,
|
||||||
|
E1153D9B2BBA3E9D00424D36 /* LoadingCard.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E1153D982BBA3E6100424D36 /* EpisodeSelector */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E1153D972BBA3E5300424D36 /* Components */,
|
||||||
|
E1C926072887565C002A7A66 /* EpisodeSelector.swift */,
|
||||||
|
);
|
||||||
|
path = EpisodeSelector;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
E1171A1A28A2215800FA1AF5 /* UserSignInView */ = {
|
E1171A1A28A2215800FA1AF5 /* UserSignInView */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -2015,6 +2071,7 @@
|
||||||
E11CEB85289984F5003E74C7 /* Extensions */ = {
|
E11CEB85289984F5003E74C7 /* Extensions */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E1A3E4CC2BB7D8C8005C59F8 /* iOSLabelExtensions.swift */,
|
||||||
E11CEB8828998522003E74C7 /* View */,
|
E11CEB8828998522003E74C7 /* View */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
|
@ -2275,6 +2332,28 @@
|
||||||
path = PlaybackButtons;
|
path = PlaybackButtons;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E172D3AF2BACA54A007B4647 /* EpisodeSelector */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E172D3B02BACA560007B4647 /* Components */,
|
||||||
|
E1DA656E28E78C9900592A73 /* EpisodeSelector.swift */,
|
||||||
|
);
|
||||||
|
path = EpisodeSelector;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E172D3B02BACA560007B4647 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E1153DB22BBA80B400424D36 /* EmptyCard.swift */,
|
||||||
|
E1A3E4C62BB74E50005C59F8 /* EpisodeCard.swift */,
|
||||||
|
E172D3B12BACA569007B4647 /* EpisodeContent.swift */,
|
||||||
|
E1A3E4CA2BB74EFD005C59F8 /* EpisodeHStack.swift */,
|
||||||
|
E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */,
|
||||||
|
E1A3E4C82BB74EA3005C59F8 /* LoadingCard.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
E178859C2780F5300094FBCF /* tvOSSLider */ = {
|
E178859C2780F5300094FBCF /* tvOSSLider */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -2466,10 +2545,10 @@
|
||||||
E18E01D7288747230022598C /* AttributeHStack.swift */,
|
E18E01D7288747230022598C /* AttributeHStack.swift */,
|
||||||
E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */,
|
E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */,
|
||||||
E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */,
|
E17AC9722955007A003D2BC2 /* DownloadTaskButton.swift */,
|
||||||
|
E172D3AF2BACA54A007B4647 /* EpisodeSelector */,
|
||||||
E17FB55A28C1266400311DFE /* GenresHStack.swift */,
|
E17FB55A28C1266400311DFE /* GenresHStack.swift */,
|
||||||
E1D8424E2932F7C400D1041A /* OverviewView.swift */,
|
E1D8424E2932F7C400D1041A /* OverviewView.swift */,
|
||||||
E18E01D8288747230022598C /* PlayButton.swift */,
|
E18E01D8288747230022598C /* PlayButton.swift */,
|
||||||
E1DA656E28E78C9900592A73 /* SeriesEpisodeSelector.swift */,
|
|
||||||
E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */,
|
E17FB55428C1250B00311DFE /* SimilarItemsHStack.swift */,
|
||||||
E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */,
|
E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */,
|
||||||
E17FB55828C125E900311DFE /* StudiosHStack.swift */,
|
E17FB55828C125E900311DFE /* StudiosHStack.swift */,
|
||||||
|
@ -2635,6 +2714,7 @@
|
||||||
E1C926032887565C002A7A66 /* ActionButtonHStack.swift */,
|
E1C926032887565C002A7A66 /* ActionButtonHStack.swift */,
|
||||||
E1C926012887565C002A7A66 /* AttributeHStack.swift */,
|
E1C926012887565C002A7A66 /* AttributeHStack.swift */,
|
||||||
E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */,
|
E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */,
|
||||||
|
E1153D982BBA3E6100424D36 /* EpisodeSelector */,
|
||||||
E1C926022887565C002A7A66 /* PlayButton.swift */,
|
E1C926022887565C002A7A66 /* PlayButton.swift */,
|
||||||
E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */,
|
E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */,
|
||||||
E169C7B7296D2E8200AE25F9 /* SpecialFeaturesHStack.swift */,
|
E169C7B7296D2E8200AE25F9 /* SpecialFeaturesHStack.swift */,
|
||||||
|
@ -2645,22 +2725,12 @@
|
||||||
E1C926042887565C002A7A66 /* SeriesItemView */ = {
|
E1C926042887565C002A7A66 /* SeriesItemView */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E1C926062887565C002A7A66 /* Components */,
|
|
||||||
E1C926052887565C002A7A66 /* SeriesItemContentView.swift */,
|
E1C926052887565C002A7A66 /* SeriesItemContentView.swift */,
|
||||||
E1C9260A2887565C002A7A66 /* SeriesItemView.swift */,
|
E1C9260A2887565C002A7A66 /* SeriesItemView.swift */,
|
||||||
);
|
);
|
||||||
path = SeriesItemView;
|
path = SeriesItemView;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
E1C926062887565C002A7A66 /* Components */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
E1C926092887565C002A7A66 /* EpisodeCard.swift */,
|
|
||||||
E1C926072887565C002A7A66 /* SeriesEpisodeSelector.swift */,
|
|
||||||
);
|
|
||||||
path = Components;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
E1CAF65C2BA345830087D991 /* MediaViewModel */ = {
|
E1CAF65C2BA345830087D991 /* MediaViewModel */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -2917,9 +2987,9 @@
|
||||||
E18443CA2A037773002DDDC8 /* UDPBroadcast */,
|
E18443CA2A037773002DDDC8 /* UDPBroadcast */,
|
||||||
E14CB6872A9FF71F001586C6 /* JellyfinAPI */,
|
E14CB6872A9FF71F001586C6 /* JellyfinAPI */,
|
||||||
E1A7B1642B9A9F7800152546 /* PreferencesView */,
|
E1A7B1642B9A9F7800152546 /* PreferencesView */,
|
||||||
E104DC932B9D89A2008F506D /* CollectionVGrid */,
|
|
||||||
E1392FEC2BA218A80034110D /* SwiftUIIntrospect */,
|
E1392FEC2BA218A80034110D /* SwiftUIIntrospect */,
|
||||||
E1392FF32BA21B470034110D /* CollectionHStack */,
|
E1153DA82BBA642A00424D36 /* CollectionVGrid */,
|
||||||
|
E1153DB02BBA734C00424D36 /* CollectionHStack */,
|
||||||
);
|
);
|
||||||
productName = "JellyfinPlayer tvOS";
|
productName = "JellyfinPlayer tvOS";
|
||||||
productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */;
|
productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */;
|
||||||
|
@ -2966,10 +3036,13 @@
|
||||||
E15D4F042B1B0C3C00442DB8 /* PreferencesView */,
|
E15D4F042B1B0C3C00442DB8 /* PreferencesView */,
|
||||||
E113A2A62B5A178D009CAAAA /* CollectionHStack */,
|
E113A2A62B5A178D009CAAAA /* CollectionHStack */,
|
||||||
E113A2A92B5A179A009CAAAA /* CollectionVGrid */,
|
E113A2A92B5A179A009CAAAA /* CollectionVGrid */,
|
||||||
E104DC8F2B9D8995008F506D /* CollectionVGrid */,
|
|
||||||
E15EFA832BA167350080E926 /* CollectionHStack */,
|
E15EFA832BA167350080E926 /* CollectionHStack */,
|
||||||
E15EFA852BA1685F0080E926 /* SwiftUIIntrospect */,
|
E15EFA852BA1685F0080E926 /* SwiftUIIntrospect */,
|
||||||
E1392FF12BA21B360034110D /* CollectionHStack */,
|
E18D6AA52BAA96F000A0D167 /* CollectionHStack */,
|
||||||
|
E1153DA32BBA614F00424D36 /* CollectionVGrid */,
|
||||||
|
E1153DA62BBA641000424D36 /* CollectionVGrid */,
|
||||||
|
E1153DAB2BBA6AD200424D36 /* CollectionHStack */,
|
||||||
|
E1153DAE2BBA734200424D36 /* CollectionHStack */,
|
||||||
);
|
);
|
||||||
productName = JellyfinPlayer;
|
productName = JellyfinPlayer;
|
||||||
productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */;
|
productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */;
|
||||||
|
@ -3039,8 +3112,8 @@
|
||||||
E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */,
|
E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */,
|
||||||
E14CB6842A9FF62A001586C6 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */,
|
E14CB6842A9FF62A001586C6 /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */,
|
||||||
E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */,
|
E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */,
|
||||||
E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */,
|
E1153DA52BBA641000424D36 /* XCRemoteSwiftPackageReference "CollectionVGrid" */,
|
||||||
E1392FF02BA21B360034110D /* XCRemoteSwiftPackageReference "CollectionHStack" */,
|
E1153DAD2BBA734200424D36 /* XCRemoteSwiftPackageReference "CollectionHStack" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
|
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
|
@ -3249,11 +3322,13 @@
|
||||||
E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */,
|
E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */,
|
||||||
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
|
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
|
||||||
E1575E95293E7B1E001665B1 /* Font.swift in Sources */,
|
E1575E95293E7B1E001665B1 /* Font.swift in Sources */,
|
||||||
|
E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
|
||||||
E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */,
|
E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */,
|
||||||
E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */,
|
E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */,
|
||||||
E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */,
|
E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */,
|
||||||
E18A8E8128D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */,
|
E18A8E8128D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */,
|
||||||
E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */,
|
E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */,
|
||||||
|
E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */,
|
||||||
E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */,
|
E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */,
|
||||||
E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */,
|
E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */,
|
||||||
E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */,
|
E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */,
|
||||||
|
@ -3391,7 +3466,7 @@
|
||||||
E133328929538D8D00EE76AB /* Files.swift in Sources */,
|
E133328929538D8D00EE76AB /* Files.swift in Sources */,
|
||||||
E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */,
|
E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */,
|
||||||
E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */,
|
E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */,
|
||||||
E1C926132887565C002A7A66 /* SeriesEpisodeSelector.swift in Sources */,
|
E1C926132887565C002A7A66 /* EpisodeSelector.swift in Sources */,
|
||||||
E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */,
|
E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */,
|
||||||
E18E02232887492B0022598C /* ImageView.swift in Sources */,
|
E18E02232887492B0022598C /* ImageView.swift in Sources */,
|
||||||
E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */,
|
E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */,
|
||||||
|
@ -3402,6 +3477,7 @@
|
||||||
E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */,
|
E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */,
|
||||||
E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */,
|
E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */,
|
||||||
E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */,
|
E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */,
|
||||||
|
E1153D942BBA3D3000424D36 /* EpisodeContent.swift in Sources */,
|
||||||
E11BDF982B865F550045C54A /* ItemTag.swift in Sources */,
|
E11BDF982B865F550045C54A /* ItemTag.swift in Sources */,
|
||||||
E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */,
|
E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */,
|
||||||
E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */,
|
E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */,
|
||||||
|
@ -3414,6 +3490,7 @@
|
||||||
E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */,
|
E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */,
|
||||||
E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */,
|
E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */,
|
||||||
C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */,
|
C4BE07722725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift in Sources */,
|
||||||
|
E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */,
|
||||||
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
|
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
|
||||||
E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */,
|
E1575E68293E77B5001665B1 /* LibraryParent.swift in Sources */,
|
||||||
E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */,
|
E1C9260D2887565C002A7A66 /* CinematicScrollView.swift in Sources */,
|
||||||
|
@ -3426,6 +3503,7 @@
|
||||||
E1E9EFEA28C6B96500CC1F8B /* ServerButton.swift in Sources */,
|
E1E9EFEA28C6B96500CC1F8B /* ServerButton.swift in Sources */,
|
||||||
E1575E65293E77B5001665B1 /* VideoPlayerJumpLength.swift in Sources */,
|
E1575E65293E77B5001665B1 /* VideoPlayerJumpLength.swift in Sources */,
|
||||||
E169C7B8296D2E8200AE25F9 /* SpecialFeaturesHStack.swift in Sources */,
|
E169C7B8296D2E8200AE25F9 /* SpecialFeaturesHStack.swift in Sources */,
|
||||||
|
E1153D962BBA3E2F00424D36 /* EpisodeHStack.swift in Sources */,
|
||||||
E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */,
|
E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */,
|
||||||
E1B5861329E32EEF00E45D6E /* Sequence.swift in Sources */,
|
E1B5861329E32EEF00E45D6E /* Sequence.swift in Sources */,
|
||||||
C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */,
|
C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */,
|
||||||
|
@ -3510,6 +3588,7 @@
|
||||||
E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */,
|
E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */,
|
||||||
E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */,
|
E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */,
|
||||||
E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */,
|
E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */,
|
||||||
|
E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */,
|
||||||
C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */,
|
C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */,
|
||||||
E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */,
|
E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */,
|
||||||
62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */,
|
62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */,
|
||||||
|
@ -3541,6 +3620,7 @@
|
||||||
6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */,
|
6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */,
|
||||||
E18A8E8528D60D0000333B9A /* VideoPlayerCoordinator.swift in Sources */,
|
E18A8E8528D60D0000333B9A /* VideoPlayerCoordinator.swift in Sources */,
|
||||||
E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */,
|
E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */,
|
||||||
|
E1A3E4CD2BB7D8C8005C59F8 /* iOSLabelExtensions.swift in Sources */,
|
||||||
E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
|
E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
|
||||||
E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */,
|
E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */,
|
||||||
E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */,
|
E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */,
|
||||||
|
@ -3559,6 +3639,7 @@
|
||||||
E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */,
|
E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */,
|
||||||
E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */,
|
E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */,
|
||||||
E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
|
E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
|
||||||
|
E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
|
||||||
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
|
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
|
||||||
C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */,
|
C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */,
|
||||||
E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
||||||
|
@ -3590,6 +3671,7 @@
|
||||||
E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */,
|
E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */,
|
||||||
E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */,
|
E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */,
|
||||||
E18ACA922A15A32F00BB4F35 /* (null) in Sources */,
|
E18ACA922A15A32F00BB4F35 /* (null) in Sources */,
|
||||||
|
E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */,
|
||||||
E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */,
|
E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */,
|
||||||
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
|
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
|
||||||
E15756322935642A00976E1F /* Double.swift in Sources */,
|
E15756322935642A00976E1F /* Double.swift in Sources */,
|
||||||
|
@ -3654,6 +3736,7 @@
|
||||||
E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */,
|
E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */,
|
||||||
E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */,
|
E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */,
|
||||||
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
|
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
|
||||||
|
E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */,
|
||||||
E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */,
|
E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */,
|
||||||
E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */,
|
E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */,
|
||||||
E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */,
|
E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */,
|
||||||
|
@ -3661,7 +3744,7 @@
|
||||||
E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */,
|
E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */,
|
||||||
E1A1528528FD191A00600579 /* TextPair.swift in Sources */,
|
E1A1528528FD191A00600579 /* TextPair.swift in Sources */,
|
||||||
6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */,
|
6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */,
|
||||||
E1DA656F28E78C9900592A73 /* SeriesEpisodeSelector.swift in Sources */,
|
E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */,
|
||||||
E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */,
|
E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */,
|
||||||
E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
|
E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
|
||||||
E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
|
E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
|
||||||
|
@ -3689,6 +3772,7 @@
|
||||||
E1D37F4E2B9CEDC400343D2B /* DeviceProfile.swift in Sources */,
|
E1D37F4E2B9CEDC400343D2B /* DeviceProfile.swift in Sources */,
|
||||||
E1EF4C412911B783008CC695 /* StreamType.swift in Sources */,
|
E1EF4C412911B783008CC695 /* StreamType.swift in Sources */,
|
||||||
6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */,
|
6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */,
|
||||||
|
E1A3E4CF2BB7E02B005C59F8 /* DelayedProgressView.swift in Sources */,
|
||||||
E1921B7628E63306003A5238 /* GestureView.swift in Sources */,
|
E1921B7628E63306003A5238 /* GestureView.swift in Sources */,
|
||||||
E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */,
|
E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */,
|
||||||
E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */,
|
E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */,
|
||||||
|
@ -3811,7 +3895,10 @@
|
||||||
6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */,
|
6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */,
|
||||||
E148128528C15472003B8787 /* SortOrder.swift in Sources */,
|
E148128528C15472003B8787 /* SortOrder.swift in Sources */,
|
||||||
E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */,
|
E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */,
|
||||||
|
E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */,
|
||||||
|
E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */,
|
||||||
E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */,
|
E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */,
|
||||||
|
E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */,
|
||||||
E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */,
|
E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */,
|
||||||
E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */,
|
E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */,
|
||||||
5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */,
|
5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */,
|
||||||
|
@ -4287,14 +4374,6 @@
|
||||||
minimumVersion = 1.0.0;
|
minimumVersion = 1.0.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/LePips/CollectionVGrid";
|
|
||||||
requirement = {
|
|
||||||
branch = main;
|
|
||||||
kind = branch;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */ = {
|
E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/kean/Pulse";
|
repositoryURL = "https://github.com/kean/Pulse";
|
||||||
|
@ -4303,7 +4382,15 @@
|
||||||
minimumVersion = 2.0.0;
|
minimumVersion = 2.0.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
E1392FF02BA21B360034110D /* XCRemoteSwiftPackageReference "CollectionHStack" */ = {
|
E1153DA52BBA641000424D36 /* XCRemoteSwiftPackageReference "CollectionVGrid" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/LePips/CollectionVGrid";
|
||||||
|
requirement = {
|
||||||
|
branch = main;
|
||||||
|
kind = branch;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
E1153DAD2BBA734200424D36 /* XCRemoteSwiftPackageReference "CollectionHStack" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/LePips/CollectionHStack";
|
repositoryURL = "https://github.com/LePips/CollectionHStack";
|
||||||
requirement = {
|
requirement = {
|
||||||
|
@ -4453,16 +4540,6 @@
|
||||||
package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */;
|
package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */;
|
||||||
productName = Algorithms;
|
productName = Algorithms;
|
||||||
};
|
};
|
||||||
E104DC8F2B9D8995008F506D /* CollectionVGrid */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */;
|
|
||||||
productName = CollectionVGrid;
|
|
||||||
};
|
|
||||||
E104DC932B9D89A2008F506D /* CollectionVGrid */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = E104DC8E2B9D8995008F506D /* XCRemoteSwiftPackageReference "CollectionVGrid" */;
|
|
||||||
productName = CollectionVGrid;
|
|
||||||
};
|
|
||||||
E107060F2942F57D00646DAF /* Pulse */ = {
|
E107060F2942F57D00646DAF /* Pulse */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */;
|
package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */;
|
||||||
|
@ -4490,6 +4567,34 @@
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = CollectionVGrid;
|
productName = CollectionVGrid;
|
||||||
};
|
};
|
||||||
|
E1153DA32BBA614F00424D36 /* CollectionVGrid */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = CollectionVGrid;
|
||||||
|
};
|
||||||
|
E1153DA62BBA641000424D36 /* CollectionVGrid */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = E1153DA52BBA641000424D36 /* XCRemoteSwiftPackageReference "CollectionVGrid" */;
|
||||||
|
productName = CollectionVGrid;
|
||||||
|
};
|
||||||
|
E1153DA82BBA642A00424D36 /* CollectionVGrid */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = E1153DA52BBA641000424D36 /* XCRemoteSwiftPackageReference "CollectionVGrid" */;
|
||||||
|
productName = CollectionVGrid;
|
||||||
|
};
|
||||||
|
E1153DAB2BBA6AD200424D36 /* CollectionHStack */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = CollectionHStack;
|
||||||
|
};
|
||||||
|
E1153DAE2BBA734200424D36 /* CollectionHStack */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = E1153DAD2BBA734200424D36 /* XCRemoteSwiftPackageReference "CollectionHStack" */;
|
||||||
|
productName = CollectionHStack;
|
||||||
|
};
|
||||||
|
E1153DB02BBA734C00424D36 /* CollectionHStack */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = E1153DAD2BBA734200424D36 /* XCRemoteSwiftPackageReference "CollectionHStack" */;
|
||||||
|
productName = CollectionHStack;
|
||||||
|
};
|
||||||
E12186DD2718F1C50010884C /* Defaults */ = {
|
E12186DD2718F1C50010884C /* Defaults */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */;
|
package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */;
|
||||||
|
@ -4505,16 +4610,6 @@
|
||||||
package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
|
package = 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */;
|
||||||
productName = SwiftUIIntrospect;
|
productName = SwiftUIIntrospect;
|
||||||
};
|
};
|
||||||
E1392FF12BA21B360034110D /* CollectionHStack */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = E1392FF02BA21B360034110D /* XCRemoteSwiftPackageReference "CollectionHStack" */;
|
|
||||||
productName = CollectionHStack;
|
|
||||||
};
|
|
||||||
E1392FF32BA21B470034110D /* CollectionHStack */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = E1392FF02BA21B360034110D /* XCRemoteSwiftPackageReference "CollectionHStack" */;
|
|
||||||
productName = CollectionHStack;
|
|
||||||
};
|
|
||||||
E13AF3B528A0C598009093AB /* Nuke */ = {
|
E13AF3B528A0C598009093AB /* Nuke */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */;
|
package = E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */;
|
||||||
|
@ -4617,6 +4712,10 @@
|
||||||
package = E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */;
|
package = E18A8E7828D5FEDF00333B9A /* XCRemoteSwiftPackageReference "VLCUI" */;
|
||||||
productName = VLCUI;
|
productName = VLCUI;
|
||||||
};
|
};
|
||||||
|
E18D6AA52BAA96F000A0D167 /* CollectionHStack */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = CollectionHStack;
|
||||||
|
};
|
||||||
E192608228D2D0DB002314B4 /* Factory */ = {
|
E192608228D2D0DB002314B4 /* Factory */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */;
|
package = E192608128D2D0DB002314B4 /* XCRemoteSwiftPackageReference "Factory" */;
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
"location" : "https://github.com/LePips/CollectionHStack",
|
"location" : "https://github.com/LePips/CollectionHStack",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "e192023a2f2ce9351cbe7fb6f01c47043de209a8"
|
"revision" : "894b595185bbfce007d60b219ee3e4013884131c"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -24,7 +24,7 @@
|
||||||
"location" : "https://github.com/LePips/CollectionVGrid",
|
"location" : "https://github.com/LePips/CollectionVGrid",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "2be0988304df1ab59a3340e41c07f94eee480e66"
|
"revision" : "91513692e56cc564f1bcbd476289ae060eb7e877"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
//
|
||||||
|
// 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 SwiftUI
|
||||||
|
|
||||||
|
// TODO: retry button and/or loading text after a few more seconds
|
||||||
|
struct DelayedProgressView: View {
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var interval = 0
|
||||||
|
|
||||||
|
private let timer: Publishers.Autoconnect<Timer.TimerPublisher>
|
||||||
|
|
||||||
|
init(interval: Double = 0.5) {
|
||||||
|
self.timer = Timer.publish(every: interval, on: .main, in: .common).autoconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
if interval > 0 {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(timer) { _ in
|
||||||
|
withAnimation {
|
||||||
|
interval += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
//
|
||||||
|
// 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 LabelStyle where Self == EpisodeSelectorLabelStyle {
|
||||||
|
|
||||||
|
static var episodeSelector: EpisodeSelectorLabelStyle {
|
||||||
|
EpisodeSelectorLabelStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LabelStyle where Self == TrailingIconLabelStyle {
|
||||||
|
|
||||||
|
static var trailingIcon: TrailingIconLabelStyle {
|
||||||
|
TrailingIconLabelStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EpisodeSelectorLabelStyle: LabelStyle {
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
HStack {
|
||||||
|
configuration.title
|
||||||
|
|
||||||
|
configuration.icon
|
||||||
|
}
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.background {
|
||||||
|
Color.tertiarySystemFill
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
.compositingGroup()
|
||||||
|
.shadow(radius: 1)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrailingIconLabelStyle: LabelStyle {
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
HStack {
|
||||||
|
configuration.title
|
||||||
|
|
||||||
|
configuration.icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -40,13 +40,13 @@ extension HomeView {
|
||||||
PosterButton(item: item, type: .landscape)
|
PosterButton(item: item, type: .landscape)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button {
|
Button {
|
||||||
viewModel.markItemPlayed(item)
|
viewModel.send(.setIsPlayed(true, item))
|
||||||
} label: {
|
} label: {
|
||||||
Label(L10n.played, systemImage: "checkmark.circle")
|
Label(L10n.played, systemImage: "checkmark.circle")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
viewModel.markItemUnplayed(item)
|
viewModel.send(.setIsPlayed(false, item))
|
||||||
} label: {
|
} label: {
|
||||||
Label(L10n.unplayed, systemImage: "minus.circle")
|
Label(L10n.unplayed, systemImage: "minus.circle")
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,24 +22,24 @@ extension HomeView {
|
||||||
private var router: HomeCoordinator.Router
|
private var router: HomeCoordinator.Router
|
||||||
|
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var viewModel: NextUpLibraryViewModel
|
var homeViewModel: HomeViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if viewModel.elements.isNotEmpty {
|
if homeViewModel.nextUpViewModel.elements.isNotEmpty {
|
||||||
PosterHStack(
|
PosterHStack(
|
||||||
title: L10n.nextUp,
|
title: L10n.nextUp,
|
||||||
type: nextUpPosterType,
|
type: nextUpPosterType,
|
||||||
items: $viewModel.elements
|
items: $homeViewModel.nextUpViewModel.elements
|
||||||
)
|
)
|
||||||
.trailing {
|
.trailing {
|
||||||
SeeAllButton()
|
SeeAllButton()
|
||||||
.onSelect {
|
.onSelect {
|
||||||
router.route(to: \.library, viewModel)
|
router.route(to: \.library, homeViewModel.nextUpViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contextMenu { item in
|
.contextMenu { item in
|
||||||
Button {
|
Button {
|
||||||
viewModel.markPlayed(item: item)
|
homeViewModel.send(.setIsPlayed(true, item))
|
||||||
} label: {
|
} label: {
|
||||||
Label(L10n.played, systemImage: "checkmark.circle")
|
Label(L10n.played, systemImage: "checkmark.circle")
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import SwiftUI
|
||||||
|
|
||||||
// TODO: seems to redraw view when popped to sometimes?
|
// TODO: seems to redraw view when popped to sometimes?
|
||||||
// - similar to MediaView TODO bug?
|
// - similar to MediaView TODO bug?
|
||||||
|
// - indicated by snapping to the top
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
|
|
||||||
@Default(.Customization.nextUpPosterType)
|
@Default(.Customization.nextUpPosterType)
|
||||||
|
@ -31,7 +32,7 @@ struct HomeView: View {
|
||||||
|
|
||||||
ContinueWatchingView(viewModel: viewModel)
|
ContinueWatchingView(viewModel: viewModel)
|
||||||
|
|
||||||
NextUpView(viewModel: viewModel.nextUpViewModel)
|
NextUpView(homeViewModel: viewModel)
|
||||||
|
|
||||||
RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel)
|
RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel)
|
||||||
|
|
||||||
|
@ -41,6 +42,9 @@ struct HomeView: View {
|
||||||
}
|
}
|
||||||
.edgePadding(.vertical)
|
.edgePadding(.vertical)
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewModel.send(.refresh)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func errorView(with error: some Error) -> some View {
|
private func errorView(with error: some Error) -> some View {
|
||||||
|
@ -52,30 +56,37 @@ struct HomeView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WrappedView {
|
WrappedView {
|
||||||
Group {
|
switch viewModel.state {
|
||||||
switch viewModel.state {
|
case .content:
|
||||||
case .content:
|
contentView
|
||||||
contentView
|
case let .error(error):
|
||||||
case let .error(error):
|
errorView(with: error)
|
||||||
errorView(with: error)
|
case .initial, .refreshing:
|
||||||
case .initial, .refreshing:
|
DelayedProgressView()
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.transition(.opacity.animation(.linear(duration: 0.1)))
|
|
||||||
}
|
}
|
||||||
|
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||||
.onFirstAppear {
|
.onFirstAppear {
|
||||||
viewModel.send(.refresh)
|
viewModel.send(.refresh)
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.home)
|
.navigationTitle(L10n.home)
|
||||||
.toolbar {
|
.topBarTrailing {
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
|
||||||
Button {
|
if viewModel.backgroundStates.contains(.refresh) {
|
||||||
router.route(to: \.settings)
|
ProgressView()
|
||||||
} label: {
|
}
|
||||||
Image(systemName: "gearshape.fill")
|
|
||||||
.accessibilityLabel(L10n.settings)
|
Button {
|
||||||
}
|
router.route(to: \.settings)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "gearshape.fill")
|
||||||
|
.accessibilityLabel(L10n.settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.afterLastDisappear { interval in
|
||||||
|
if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) {
|
||||||
|
viewModel.send(.backgroundRefresh)
|
||||||
|
viewModel.notificationsReceived.remove(.itemMetadataDidChange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// TODO: fix with shorter text
|
// TODO: fix with shorter text
|
||||||
|
// - seems to center align
|
||||||
|
|
||||||
struct ItemOverviewView: View {
|
struct ItemOverviewView: View {
|
||||||
|
|
||||||
|
|
|
@ -15,11 +15,12 @@ extension ItemView {
|
||||||
|
|
||||||
struct ActionButtonHStack: View {
|
struct ActionButtonHStack: View {
|
||||||
|
|
||||||
|
@Injected(Container.downloadManager)
|
||||||
|
private var downloadManager: DownloadManager
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
private var router: ItemCoordinator.Router
|
private var router: ItemCoordinator.Router
|
||||||
|
|
||||||
@ObservedObject
|
|
||||||
private var downloadManager: DownloadManager
|
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
private var viewModel: ItemViewModel
|
private var viewModel: ItemViewModel
|
||||||
|
|
||||||
|
@ -28,17 +29,15 @@ extension ItemView {
|
||||||
init(viewModel: ItemViewModel, equalSpacing: Bool = true) {
|
init(viewModel: ItemViewModel, equalSpacing: Bool = true) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.equalSpacing = equalSpacing
|
self.equalSpacing = equalSpacing
|
||||||
|
|
||||||
self.downloadManager = Container.downloadManager()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 15) {
|
HStack(alignment: .center, spacing: 15) {
|
||||||
Button {
|
Button {
|
||||||
UIDevice.impact(.light)
|
UIDevice.impact(.light)
|
||||||
viewModel.toggleWatchState()
|
viewModel.send(.toggleIsPlayed)
|
||||||
} label: {
|
} label: {
|
||||||
if viewModel.isPlayed {
|
if viewModel.item.userData?.isPlayed ?? false {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.symbolRenderingMode(.palette)
|
.symbolRenderingMode(.palette)
|
||||||
.foregroundStyle(
|
.foregroundStyle(
|
||||||
|
@ -56,9 +55,9 @@ extension ItemView {
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
UIDevice.impact(.light)
|
UIDevice.impact(.light)
|
||||||
viewModel.toggleFavoriteState()
|
viewModel.send(.toggleIsFavorite)
|
||||||
} label: {
|
} label: {
|
||||||
if viewModel.isFavorited {
|
if viewModel.item.userData?.isFavorite ?? false {
|
||||||
Image(systemName: "heart.fill")
|
Image(systemName: "heart.fill")
|
||||||
.symbolRenderingMode(.palette)
|
.symbolRenderingMode(.palette)
|
||||||
.foregroundStyle(Color.red)
|
.foregroundStyle(Color.red)
|
||||||
|
@ -78,7 +77,7 @@ extension ItemView {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(mediaSources, id: \.hashValue) { mediaSource in
|
ForEach(mediaSources, id: \.hashValue) { mediaSource in
|
||||||
Button {
|
Button {
|
||||||
viewModel.selectedMediaSource = mediaSource
|
// viewModel.selectedMediaSource = mediaSource
|
||||||
} label: {
|
} label: {
|
||||||
if let selectedMediaSource = viewModel.selectedMediaSource, selectedMediaSource == mediaSource {
|
if let selectedMediaSource = viewModel.selectedMediaSource, selectedMediaSource == mediaSource {
|
||||||
Label(selectedMediaSource.displayTitle, systemImage: "checkmark")
|
Label(selectedMediaSource.displayTitle, systemImage: "checkmark")
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// Swiftfin is subject to the terms of the Mozilla Public
|
||||||
|
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct EmptyCard: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Color.secondarySystemFill
|
||||||
|
.opacity(0.75)
|
||||||
|
.posterStyle(.landscape)
|
||||||
|
|
||||||
|
SeriesEpisodeSelector.EpisodeContent(
|
||||||
|
subHeader: .emptyDash,
|
||||||
|
header: L10n.noResults,
|
||||||
|
content: L10n.noEpisodesAvailable
|
||||||
|
)
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,75 @@
|
||||||
|
//
|
||||||
|
// 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 SwiftUI
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct EpisodeCard: View {
|
||||||
|
|
||||||
|
@EnvironmentObject
|
||||||
|
private var mainRouter: MainCoordinator.Router
|
||||||
|
@EnvironmentObject
|
||||||
|
private var router: ItemCoordinator.Router
|
||||||
|
|
||||||
|
let episode: BaseItemDto
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var overlayView: some View {
|
||||||
|
if let progressLabel = episode.progressLabel {
|
||||||
|
LandscapePosterProgressBar(
|
||||||
|
title: progressLabel,
|
||||||
|
progress: (episode.userData?.playedPercentage ?? 0) / 100
|
||||||
|
)
|
||||||
|
} else if episode.userData?.isPlayed ?? false {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
Color.clear
|
||||||
|
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 30, height: 30, alignment: .bottomTrailing)
|
||||||
|
.paletteOverlayRendering(color: .white)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
PosterButton(
|
||||||
|
item: episode,
|
||||||
|
type: .landscape,
|
||||||
|
singleImage: true
|
||||||
|
)
|
||||||
|
.content {
|
||||||
|
let content: String = if episode.isUnaired {
|
||||||
|
episode.airDateLabel ?? L10n.noOverviewAvailable
|
||||||
|
} else {
|
||||||
|
episode.overview ?? L10n.noOverviewAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
SeriesEpisodeSelector.EpisodeContent(
|
||||||
|
subHeader: episode.episodeLocator ?? .emptyDash,
|
||||||
|
header: episode.displayTitle,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
.onSelect {
|
||||||
|
router.route(to: \.item, episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.imageOverlay {
|
||||||
|
overlayView
|
||||||
|
}
|
||||||
|
.onSelect {
|
||||||
|
guard let mediaSource = episode.mediaSources?.first else { return }
|
||||||
|
mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: episode, mediaSource: mediaSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 Defaults
|
||||||
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct EpisodeContent: View {
|
||||||
|
|
||||||
|
@Default(.accentColor)
|
||||||
|
private var accentColor
|
||||||
|
|
||||||
|
private var onSelect: () -> Void
|
||||||
|
|
||||||
|
let subHeader: String
|
||||||
|
let header: String
|
||||||
|
let content: String
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var subHeaderView: some View {
|
||||||
|
Text(subHeader)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var headerView: some View {
|
||||||
|
Text(header)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.padding(.bottom, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentView: some View {
|
||||||
|
Text(content)
|
||||||
|
.font(.caption.weight(.light))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.backport
|
||||||
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
onSelect()
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
subHeaderView
|
||||||
|
|
||||||
|
headerView
|
||||||
|
|
||||||
|
contentView
|
||||||
|
|
||||||
|
L10n.seeMore.text
|
||||||
|
.font(.caption.weight(.light))
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector.EpisodeContent {
|
||||||
|
|
||||||
|
init(
|
||||||
|
subHeader: String,
|
||||||
|
header: String,
|
||||||
|
content: String
|
||||||
|
) {
|
||||||
|
self.subHeader = subHeader
|
||||||
|
self.header = header
|
||||||
|
self.content = content
|
||||||
|
self.onSelect = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onSelect(perform action: @escaping () -> Void) -> Self {
|
||||||
|
copy(modifying: \.onSelect, with: action)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,123 @@
|
||||||
|
//
|
||||||
|
// 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 CollectionHStack
|
||||||
|
import Foundation
|
||||||
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// TODO: The content/loading/error states are implemented as different CollectionHStacks because it was just easy.
|
||||||
|
// A theoretically better implementation would be a single CollectionHStack with cards that represent the state instead.
|
||||||
|
extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct EpisodeHStack: View {
|
||||||
|
|
||||||
|
@ObservedObject
|
||||||
|
var viewModel: SeasonItemViewModel
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var didScrollToPlayButtonItem = false
|
||||||
|
|
||||||
|
@StateObject
|
||||||
|
private var proxy = CollectionHStackProxy<BaseItemDto>()
|
||||||
|
|
||||||
|
let playButtonItem: BaseItemDto?
|
||||||
|
|
||||||
|
private func contentView(viewModel: SeasonItemViewModel) -> some View {
|
||||||
|
CollectionHStack(
|
||||||
|
$viewModel.elements,
|
||||||
|
columns: UIDevice.isPhone ? 1.5 : 3.5
|
||||||
|
) { episode in
|
||||||
|
SeriesEpisodeSelector.EpisodeCard(episode: episode)
|
||||||
|
}
|
||||||
|
.scrollBehavior(.continuousLeadingEdge)
|
||||||
|
.insets(horizontal: EdgeInsets.defaultEdgePadding)
|
||||||
|
.itemSpacing(EdgeInsets.defaultEdgePadding / 2)
|
||||||
|
.proxy(proxy)
|
||||||
|
.onFirstAppear {
|
||||||
|
guard !didScrollToPlayButtonItem else { return }
|
||||||
|
didScrollToPlayButtonItem = true
|
||||||
|
|
||||||
|
// good enough?
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
guard let playButtonItem else { return }
|
||||||
|
proxy.scrollTo(element: playButtonItem, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch viewModel.state {
|
||||||
|
case .content:
|
||||||
|
if viewModel.elements.isEmpty {
|
||||||
|
EmptyHStack()
|
||||||
|
} else {
|
||||||
|
contentView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
case let .error(error):
|
||||||
|
ErrorHStack(viewModel: viewModel, error: error)
|
||||||
|
case .initial, .refreshing:
|
||||||
|
LoadingHStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyHStack: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CollectionHStack(
|
||||||
|
0 ..< 1,
|
||||||
|
columns: UIDevice.isPhone ? 1.5 : 3.5
|
||||||
|
) { _ in
|
||||||
|
SeriesEpisodeSelector.EmptyCard()
|
||||||
|
}
|
||||||
|
.allowScrolling(false)
|
||||||
|
.insets(horizontal: EdgeInsets.defaultEdgePadding)
|
||||||
|
.itemSpacing(EdgeInsets.defaultEdgePadding / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: better refresh design
|
||||||
|
struct ErrorHStack: View {
|
||||||
|
|
||||||
|
@ObservedObject
|
||||||
|
var viewModel: SeasonItemViewModel
|
||||||
|
|
||||||
|
let error: JellyfinAPIError
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CollectionHStack(
|
||||||
|
0 ..< 1,
|
||||||
|
columns: UIDevice.isPhone ? 1.5 : 3.5
|
||||||
|
) { _ in
|
||||||
|
SeriesEpisodeSelector.ErrorCard(error: error)
|
||||||
|
.onSelect {
|
||||||
|
viewModel.send(.refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.allowScrolling(false)
|
||||||
|
.insets(horizontal: EdgeInsets.defaultEdgePadding)
|
||||||
|
.itemSpacing(EdgeInsets.defaultEdgePadding / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoadingHStack: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CollectionHStack(
|
||||||
|
0 ..< Int.random(in: 2 ..< 5),
|
||||||
|
columns: UIDevice.isPhone ? 1.5 : 3.5
|
||||||
|
) { _ in
|
||||||
|
SeriesEpisodeSelector.LoadingCard()
|
||||||
|
}
|
||||||
|
.allowScrolling(false)
|
||||||
|
.insets(horizontal: EdgeInsets.defaultEdgePadding)
|
||||||
|
.itemSpacing(EdgeInsets.defaultEdgePadding / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
//
|
||||||
|
// 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 SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct ErrorCard: View {
|
||||||
|
|
||||||
|
let error: JellyfinAPIError
|
||||||
|
private var onSelect: () -> Void
|
||||||
|
|
||||||
|
init(error: JellyfinAPIError) {
|
||||||
|
self.error = error
|
||||||
|
self.onSelect = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onSelect(perform action: @escaping () -> Void) -> Self {
|
||||||
|
copy(modifying: \.onSelect, with: action)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
onSelect()
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Color.secondarySystemFill
|
||||||
|
.opacity(0.75)
|
||||||
|
.posterStyle(.landscape)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "arrow.clockwise.circle.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
}
|
||||||
|
|
||||||
|
SeriesEpisodeSelector.EpisodeContent(
|
||||||
|
subHeader: .emptyDash,
|
||||||
|
header: L10n.error,
|
||||||
|
content: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// Swiftfin is subject to the terms of the Mozilla Public
|
||||||
|
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension SeriesEpisodeSelector {
|
||||||
|
|
||||||
|
struct LoadingCard: View {
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Color.secondarySystemFill
|
||||||
|
.opacity(0.75)
|
||||||
|
.posterStyle(.landscape)
|
||||||
|
|
||||||
|
SeriesEpisodeSelector.EpisodeContent(
|
||||||
|
subHeader: String.random(count: 7 ..< 12),
|
||||||
|
header: String.random(count: 10 ..< 20),
|
||||||
|
content: String.random(count: 20 ..< 80)
|
||||||
|
)
|
||||||
|
.redacted(reason: .placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
//
|
||||||
|
// 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 CollectionHStack
|
||||||
|
import Defaults
|
||||||
|
import JellyfinAPI
|
||||||
|
import OrderedCollections
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SeriesEpisodeSelector: View {
|
||||||
|
|
||||||
|
@ObservedObject
|
||||||
|
var viewModel: SeriesItemViewModel
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var didSelectPlayButtonSeason = false
|
||||||
|
@State
|
||||||
|
private var selection: SeasonItemViewModel?
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var seasonSelectorMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(viewModel.seasons, id: \.season.id) { seasonViewModel in
|
||||||
|
Button {
|
||||||
|
selection = seasonViewModel
|
||||||
|
} label: {
|
||||||
|
if seasonViewModel == selection {
|
||||||
|
Label(seasonViewModel.season.displayTitle, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(seasonViewModel.season.displayTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
selection?.season.displayTitle ?? .emptyDash,
|
||||||
|
systemImage: "chevron.down"
|
||||||
|
)
|
||||||
|
.labelStyle(.episodeSelector)
|
||||||
|
}
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
seasonSelectorMenu
|
||||||
|
.edgePadding([.bottom, .horizontal])
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if let selection {
|
||||||
|
EpisodeHStack(viewModel: selection, playButtonItem: viewModel.playButtonItem)
|
||||||
|
} else {
|
||||||
|
LoadingHStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.opacity.animation(.linear(duration: 0.1)))
|
||||||
|
}
|
||||||
|
.onReceive(viewModel.playButtonItem.publisher) { newValue in
|
||||||
|
|
||||||
|
guard !didSelectPlayButtonSeason else { return }
|
||||||
|
didSelectPlayButtonSeason = true
|
||||||
|
|
||||||
|
if let season = viewModel.seasons.first(where: { $0.season.id == newValue.seasonID }) {
|
||||||
|
selection = season
|
||||||
|
} else {
|
||||||
|
selection = viewModel.seasons.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: selection) { newValue in
|
||||||
|
guard let newValue else { return }
|
||||||
|
|
||||||
|
if newValue.state == .initial {
|
||||||
|
newValue.send(.refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,14 @@ extension ItemView {
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var viewModel: ItemViewModel
|
var viewModel: ItemViewModel
|
||||||
|
|
||||||
|
private var title: String {
|
||||||
|
if let seriesViewModel = viewModel as? SeriesItemViewModel {
|
||||||
|
return seriesViewModel.playButtonItem?.seasonEpisodeLabel ?? L10n.play
|
||||||
|
} else {
|
||||||
|
return viewModel.playButtonItem?.playButtonLabel ?? L10n.play
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource {
|
if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource {
|
||||||
|
@ -43,13 +51,14 @@ extension ItemView {
|
||||||
Image(systemName: "play.fill")
|
Image(systemName: "play.fill")
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 20))
|
||||||
|
|
||||||
Text(viewModel.playButtonText())
|
Text(title)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : accentColor.overlayColor)
|
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : accentColor.overlayColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(viewModel.playButtonItem == nil)
|
||||||
// .contextMenu {
|
// .contextMenu {
|
||||||
// if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 {
|
// if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 {
|
||||||
// Button {
|
// Button {
|
||||||
|
|
|
@ -1,181 +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 CollectionHStack
|
|
||||||
import Defaults
|
|
||||||
import JellyfinAPI
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SeriesEpisodeSelector: View {
|
|
||||||
|
|
||||||
@EnvironmentObject
|
|
||||||
private var mainRouter: MainCoordinator.Router
|
|
||||||
|
|
||||||
@ObservedObject
|
|
||||||
var viewModel: SeriesItemViewModel
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var selectorMenu: some View {
|
|
||||||
Menu {
|
|
||||||
ForEach(viewModel.menuSections.keys.sorted(by: { viewModel.menuSectionSort($0, $1) }), id: \.displayTitle) { section in
|
|
||||||
Button {
|
|
||||||
viewModel.select(section: section)
|
|
||||||
} label: {
|
|
||||||
if section == viewModel.menuSelection {
|
|
||||||
Label(section.displayTitle, systemImage: "checkmark")
|
|
||||||
} else {
|
|
||||||
Text(section.displayTitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 5) {
|
|
||||||
Group {
|
|
||||||
Text(viewModel.menuSelection?.displayTitle ?? L10n.unknown)
|
|
||||||
.fixedSize()
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
}
|
|
||||||
.font(.title3.weight(.semibold))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.bottom)
|
|
||||||
.fixedSize()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
selectorMenu
|
|
||||||
.edgePadding(.horizontal)
|
|
||||||
|
|
||||||
if viewModel.currentItems.isEmpty {
|
|
||||||
EmptyView()
|
|
||||||
} else {
|
|
||||||
CollectionHStack(
|
|
||||||
$viewModel.currentItems,
|
|
||||||
columns: UIDevice.isPhone ? 1.5 : 3.5
|
|
||||||
) { item in
|
|
||||||
PosterButton(
|
|
||||||
item: item,
|
|
||||||
type: .landscape,
|
|
||||||
singleImage: true
|
|
||||||
)
|
|
||||||
.content {
|
|
||||||
EpisodeContent(episode: item)
|
|
||||||
}
|
|
||||||
.imageOverlay {
|
|
||||||
EpisodeOverlay(episode: item)
|
|
||||||
}
|
|
||||||
.onSelect {
|
|
||||||
guard let mediaSource = item.mediaSources?.first else { return }
|
|
||||||
mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: item, mediaSource: mediaSource))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.scrollBehavior(.continuousLeadingEdge)
|
|
||||||
.insets(horizontal: EdgeInsets.defaultEdgePadding)
|
|
||||||
.itemSpacing(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SeriesEpisodeSelector {
|
|
||||||
|
|
||||||
struct EpisodeOverlay: View {
|
|
||||||
|
|
||||||
let episode: BaseItemDto
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let progressLabel = episode.progressLabel {
|
|
||||||
LandscapePosterProgressBar(
|
|
||||||
title: progressLabel,
|
|
||||||
progress: (episode.userData?.playedPercentage ?? 0) / 100
|
|
||||||
)
|
|
||||||
} else if episode.userData?.isPlayed ?? false {
|
|
||||||
ZStack(alignment: .bottomTrailing) {
|
|
||||||
Color.clear
|
|
||||||
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 30, height: 30, alignment: .bottomTrailing)
|
|
||||||
.paletteOverlayRendering(color: .white)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EpisodeContent: View {
|
|
||||||
|
|
||||||
@Default(.accentColor)
|
|
||||||
private var accentColor
|
|
||||||
|
|
||||||
@EnvironmentObject
|
|
||||||
private var router: ItemCoordinator.Router
|
|
||||||
@ScaledMetric
|
|
||||||
private var staticOverviewHeight: CGFloat = 50
|
|
||||||
|
|
||||||
let episode: BaseItemDto
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var subHeader: some View {
|
|
||||||
Text(episode.episodeLocator ?? L10n.unknown)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var header: some View {
|
|
||||||
Text(episode.displayTitle)
|
|
||||||
.font(.body)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.padding(.bottom, 1)
|
|
||||||
.lineLimit(2)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: why the static overview height?
|
|
||||||
@ViewBuilder
|
|
||||||
private var content: some View {
|
|
||||||
Group {
|
|
||||||
ZStack(alignment: .topLeading) {
|
|
||||||
Color.clear
|
|
||||||
.frame(height: staticOverviewHeight)
|
|
||||||
|
|
||||||
if episode.isUnaired {
|
|
||||||
Text(episode.airDateLabel ?? L10n.noOverviewAvailable)
|
|
||||||
} else {
|
|
||||||
Text(episode.overview ?? L10n.noOverviewAvailable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
L10n.seeMore.text
|
|
||||||
.font(.footnote)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(accentColor)
|
|
||||||
}
|
|
||||||
.font(.caption.weight(.light))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.lineLimit(3)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button {
|
|
||||||
router.route(to: \.item, episode)
|
|
||||||
} label: {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
subHeader
|
|
||||||
|
|
||||||
header
|
|
||||||
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -13,40 +13,91 @@ import WidgetKit
|
||||||
|
|
||||||
struct ItemView: View {
|
struct ItemView: View {
|
||||||
|
|
||||||
let item: BaseItemDto
|
@StateObject
|
||||||
|
private var viewModel: ItemViewModel
|
||||||
|
|
||||||
|
private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel {
|
||||||
|
switch item.type {
|
||||||
|
case .boxSet:
|
||||||
|
return CollectionItemViewModel(item: item)
|
||||||
|
case .episode:
|
||||||
|
return EpisodeItemViewModel(item: item)
|
||||||
|
case .movie:
|
||||||
|
return MovieItemViewModel(item: item)
|
||||||
|
case .series:
|
||||||
|
return SeriesItemViewModel(item: item)
|
||||||
|
default:
|
||||||
|
assertionFailure("Unsupported item")
|
||||||
|
return ItemViewModel(item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(item: BaseItemDto) {
|
||||||
|
self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var padView: some View {
|
||||||
|
switch viewModel.item.type {
|
||||||
|
case .boxSet:
|
||||||
|
iPadOSCollectionItemView(viewModel: viewModel as! CollectionItemViewModel)
|
||||||
|
case .episode:
|
||||||
|
iPadOSEpisodeItemView(viewModel: viewModel as! EpisodeItemViewModel)
|
||||||
|
case .movie:
|
||||||
|
iPadOSMovieItemView(viewModel: viewModel as! MovieItemViewModel)
|
||||||
|
case .series:
|
||||||
|
iPadOSSeriesItemView(viewModel: viewModel as! SeriesItemViewModel)
|
||||||
|
default:
|
||||||
|
Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var phoneView: some View {
|
||||||
|
switch viewModel.item.type {
|
||||||
|
case .boxSet:
|
||||||
|
CollectionItemView(viewModel: viewModel as! CollectionItemViewModel)
|
||||||
|
case .episode:
|
||||||
|
EpisodeItemView(viewModel: viewModel as! EpisodeItemViewModel)
|
||||||
|
case .movie:
|
||||||
|
MovieItemView(viewModel: viewModel as! MovieItemViewModel)
|
||||||
|
case .series:
|
||||||
|
SeriesItemView(viewModel: viewModel as! SeriesItemViewModel)
|
||||||
|
default:
|
||||||
|
Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentView: some View {
|
||||||
|
if UIDevice.isPad {
|
||||||
|
padView
|
||||||
|
} else {
|
||||||
|
phoneView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
WrappedView {
|
||||||
switch item.type {
|
switch viewModel.state {
|
||||||
case .movie:
|
case .content:
|
||||||
if UIDevice.isPad {
|
contentView
|
||||||
iPadOSMovieItemView(viewModel: .init(item: item))
|
.navigationTitle(viewModel.item.displayTitle)
|
||||||
} else {
|
case let .error(error):
|
||||||
MovieItemView(viewModel: .init(item: item))
|
ErrorView(error: error)
|
||||||
}
|
case .initial, .refreshing:
|
||||||
case .series:
|
DelayedProgressView()
|
||||||
if UIDevice.isPad {
|
|
||||||
iPadOSSeriesItemView(viewModel: .init(item: item))
|
|
||||||
} else {
|
|
||||||
SeriesItemView(viewModel: .init(item: item))
|
|
||||||
}
|
|
||||||
case .episode:
|
|
||||||
if UIDevice.isPad {
|
|
||||||
iPadOSEpisodeItemView(viewModel: .init(item: item))
|
|
||||||
} else {
|
|
||||||
EpisodeItemView(viewModel: .init(item: item))
|
|
||||||
}
|
|
||||||
case .boxSet:
|
|
||||||
if UIDevice.isPad {
|
|
||||||
iPadOSCollectionItemView(viewModel: .init(item: item))
|
|
||||||
} else {
|
|
||||||
CollectionItemView(viewModel: .init(item: item))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
Text(L10n.notImplementedYetWithType(item.type ?? "--"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle(item.displayTitle)
|
.onFirstAppear {
|
||||||
|
viewModel.send(.refresh)
|
||||||
|
}
|
||||||
|
.topBarTrailing {
|
||||||
|
if viewModel.backgroundStates.contains(.refresh) {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,6 +46,16 @@ extension CollectionItemView {
|
||||||
type: .portrait,
|
type: .portrait,
|
||||||
items: viewModel.collectionItems
|
items: viewModel.collectionItems
|
||||||
)
|
)
|
||||||
|
.trailing {
|
||||||
|
SeeAllButton()
|
||||||
|
.onSelect {
|
||||||
|
let viewModel = ItemLibraryViewModel(
|
||||||
|
title: viewModel.item.displayTitle,
|
||||||
|
viewModel.collectionItems
|
||||||
|
)
|
||||||
|
router.route(to: \.library, viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onSelect { item in
|
.onSelect { item in
|
||||||
router.route(to: \.item, item)
|
router.route(to: \.item, item)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Defaults
|
import Defaults
|
||||||
|
import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CollectionItemView: View {
|
struct CollectionItemView: View {
|
||||||
|
|
|
@ -114,8 +114,8 @@ extension EpisodeItemView.ContentView {
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
DotHStack {
|
DotHStack {
|
||||||
if let episodeLocation = viewModel.item.episodeLocator {
|
if let seasonEpisodeLabel = viewModel.item.seasonEpisodeLabel {
|
||||||
Text(episodeLocation)
|
Text(seasonEpisodeLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let productionYear = viewModel.item.premiereDateYear {
|
if let productionYear = viewModel.item.premiereDateYear {
|
||||||
|
|
|
@ -23,19 +23,13 @@ struct EpisodeItemView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
ContentView(viewModel: viewModel)
|
ContentView(viewModel: viewModel)
|
||||||
|
.edgePadding(.bottom)
|
||||||
}
|
}
|
||||||
.scrollViewOffset($scrollViewOffset)
|
.scrollViewOffset($scrollViewOffset)
|
||||||
.navigationBarOffset(
|
.navigationBarOffset(
|
||||||
$scrollViewOffset,
|
$scrollViewOffset,
|
||||||
start: 0,
|
start: 0,
|
||||||
end: 30
|
end: 10
|
||||||
)
|
)
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,6 +64,7 @@ extension MovieItemView {
|
||||||
|
|
||||||
ItemView.AboutView(viewModel: viewModel)
|
ItemView.AboutView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
.animation(.linear(duration: 0.2), value: viewModel.item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,7 @@ extension ItemView {
|
||||||
cinematicItemViewTypeUsePrimaryImage ? .primary : .backdrop,
|
cinematicItemViewTypeUsePrimaryImage ? .primary : .backdrop,
|
||||||
maxWidth: UIScreen.main.bounds.width
|
maxWidth: UIScreen.main.bounds.width
|
||||||
))
|
))
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(height: UIScreen.main.bounds.height * 0.6)
|
.frame(height: UIScreen.main.bounds.height * 0.6)
|
||||||
.bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor)
|
.bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
@ -108,11 +109,9 @@ extension ItemView {
|
||||||
) {
|
) {
|
||||||
headerView
|
headerView
|
||||||
}
|
}
|
||||||
.toolbar {
|
.topBarTrailing {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
if viewModel.state == .refreshing {
|
||||||
if viewModel.isLoading {
|
ProgressView()
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -137,20 +136,19 @@ extension ItemView.CinematicScrollView {
|
||||||
VStack(alignment: .center, spacing: 10) {
|
VStack(alignment: .center, spacing: 10) {
|
||||||
if !cinematicItemViewTypeUsePrimaryImage {
|
if !cinematicItemViewTypeUsePrimaryImage {
|
||||||
ImageView(viewModel.item.imageURL(.logo, maxWidth: UIScreen.main.bounds.width))
|
ImageView(viewModel.item.imageURL(.logo, maxWidth: UIScreen.main.bounds.width))
|
||||||
// .resizingMode(.aspectFit)
|
.placeholder {
|
||||||
.placeholder {
|
EmptyView()
|
||||||
EmptyView()
|
}
|
||||||
}
|
.failure {
|
||||||
.failure {
|
MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 100)
|
||||||
MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 100)
|
.font(.largeTitle.weight(.semibold))
|
||||||
.font(.largeTitle.weight(.semibold))
|
.lineLimit(2)
|
||||||
.lineLimit(2)
|
.multilineTextAlignment(.center)
|
||||||
.multilineTextAlignment(.center)
|
.foregroundColor(.white)
|
||||||
.foregroundColor(.white)
|
}
|
||||||
}
|
.aspectRatio(contentMode: .fit)
|
||||||
.aspectRatio(contentMode: .fit)
|
.frame(height: 100)
|
||||||
.frame(height: 100)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
} else {
|
} else {
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
|
|
|
@ -85,11 +85,10 @@ extension ItemView {
|
||||||
ItemView.OverviewView(item: viewModel.item)
|
ItemView.OverviewView(item: viewModel.item)
|
||||||
.overviewLineLimit(4)
|
.overviewLineLimit(4)
|
||||||
.taglineLineLimit(2)
|
.taglineLineLimit(2)
|
||||||
.padding(.top)
|
.edgePadding()
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
content()
|
content()
|
||||||
.padding(.vertical)
|
.edgePadding(.bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.edgesIgnoringSafeArea(.top)
|
.edgesIgnoringSafeArea(.top)
|
||||||
|
@ -106,13 +105,6 @@ extension ItemView {
|
||||||
) {
|
) {
|
||||||
headerView
|
headerView
|
||||||
}
|
}
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,8 +37,19 @@ extension ItemView {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var headerView: some View {
|
private var headerView: some View {
|
||||||
ImageView(viewModel.item.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width))
|
ImageView(viewModel.item.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width))
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(height: UIScreen.main.bounds.height * 0.35)
|
.frame(height: UIScreen.main.bounds.height * 0.35)
|
||||||
.bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor)
|
.bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor)
|
||||||
|
.onAppear {
|
||||||
|
if let backdropBlurHash = viewModel.item.blurHash(.backdrop) {
|
||||||
|
let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB
|
||||||
|
blurHashBottomEdgeColor = Color(
|
||||||
|
red: Double(bottomRGB.0),
|
||||||
|
green: Double(bottomRGB.1),
|
||||||
|
blue: Double(bottomRGB.2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@ -96,21 +107,9 @@ extension ItemView {
|
||||||
) {
|
) {
|
||||||
headerView
|
headerView
|
||||||
}
|
}
|
||||||
.toolbar {
|
.topBarTrailing {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
if viewModel.state == .refreshing {
|
||||||
if viewModel.isLoading {
|
ProgressView()
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
if let backdropBlurHash = viewModel.item.blurHash(.backdrop) {
|
|
||||||
let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB
|
|
||||||
blurHashBottomEdgeColor = Color(
|
|
||||||
red: Double(bottomRGB.0),
|
|
||||||
green: Double(bottomRGB.1),
|
|
||||||
blue: Double(bottomRGB.2)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -173,7 +172,10 @@ extension ItemView.CompactPosterScrollView {
|
||||||
// MARK: Portrait Image
|
// MARK: Portrait Image
|
||||||
|
|
||||||
ImageView(viewModel.item.imageSource(.primary, maxWidth: 130))
|
ImageView(viewModel.item.imageSource(.primary, maxWidth: 130))
|
||||||
.aspectRatio(2 / 3, contentMode: .fit)
|
.failure {
|
||||||
|
SystemImageContentView(systemName: viewModel.item.typeSystemImage)
|
||||||
|
}
|
||||||
|
.posterStyle(.portrait, contentMode: .fit)
|
||||||
.frame(width: 130)
|
.frame(width: 130)
|
||||||
.accessibilityIgnoresInvertColors()
|
.accessibilityIgnoresInvertColors()
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,9 @@ extension SeriesItemView {
|
||||||
|
|
||||||
// MARK: Episodes
|
// MARK: Episodes
|
||||||
|
|
||||||
SeriesEpisodeSelector(viewModel: viewModel)
|
if viewModel.seasons.isNotEmpty {
|
||||||
|
SeriesEpisodeSelector(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Genres
|
// MARK: Genres
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct iPadOSCollectionItemView: View {
|
struct iPadOSCollectionItemView: View {
|
||||||
|
|
|
@ -11,9 +11,6 @@ import SwiftUI
|
||||||
|
|
||||||
struct iPadOSEpisodeItemView: View {
|
struct iPadOSEpisodeItemView: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
|
||||||
private var router: ItemCoordinator.Router
|
|
||||||
|
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var viewModel: EpisodeItemViewModel
|
var viewModel: EpisodeItemViewModel
|
||||||
|
|
||||||
|
|
|
@ -18,14 +18,16 @@ extension ItemView {
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var viewModel: ItemViewModel
|
var viewModel: ItemViewModel
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var globalSize: CGSize = .zero
|
||||||
@State
|
@State
|
||||||
private var scrollViewOffset: CGFloat = 0
|
private var scrollViewOffset: CGFloat = 0
|
||||||
|
|
||||||
let content: () -> Content
|
let content: () -> Content
|
||||||
|
|
||||||
private var topOpacity: CGFloat {
|
private var topOpacity: CGFloat {
|
||||||
let start = UIScreen.main.bounds.height * 0.45
|
let start = globalSize.isLandscape ? globalSize.height * 0.45 : globalSize.height * 0.25
|
||||||
let end = UIScreen.main.bounds.height * 0.65
|
let end = globalSize.isLandscape ? globalSize.height * 0.65 : globalSize.height * 0.30
|
||||||
let diff = end - start
|
let diff = end - start
|
||||||
let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1)
|
let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1)
|
||||||
return opacity
|
return opacity
|
||||||
|
@ -40,7 +42,8 @@ extension ItemView {
|
||||||
ImageView(viewModel.item.imageSource(.backdrop, maxWidth: 1920))
|
ImageView(viewModel.item.imageSource(.backdrop, maxWidth: 1920))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: UIScreen.main.bounds.height * 0.8)
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(height: globalSize.isLandscape ? globalSize.height * 0.8 : globalSize.height * 0.4)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@ -50,10 +53,9 @@ extension ItemView {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
OverlayView(viewModel: viewModel)
|
OverlayView(viewModel: viewModel)
|
||||||
.padding2(.horizontal)
|
.edgePadding()
|
||||||
.padding2(.bottom)
|
|
||||||
}
|
}
|
||||||
.frame(height: UIScreen.main.bounds.height * 0.8)
|
.frame(height: globalSize.isLandscape ? globalSize.height * 0.8 : globalSize.height * 0.4)
|
||||||
.background {
|
.background {
|
||||||
BlurView(style: .systemThinMaterialDark)
|
BlurView(style: .systemThinMaterialDark)
|
||||||
.mask {
|
.mask {
|
||||||
|
@ -82,23 +84,17 @@ extension ItemView {
|
||||||
.scrollViewOffset($scrollViewOffset)
|
.scrollViewOffset($scrollViewOffset)
|
||||||
.navigationBarOffset(
|
.navigationBarOffset(
|
||||||
$scrollViewOffset,
|
$scrollViewOffset,
|
||||||
start: UIScreen.main.bounds.height * 0.65,
|
start: globalSize.isLandscape ? globalSize.height * 0.65 : globalSize.height * 0.30,
|
||||||
end: UIScreen.main.bounds.height * 0.65 + 50
|
end: globalSize.isLandscape ? globalSize.height * 0.65 + 50 : globalSize.height * 0.30 + 50
|
||||||
)
|
)
|
||||||
.backgroundParallaxHeader(
|
.backgroundParallaxHeader(
|
||||||
$scrollViewOffset,
|
$scrollViewOffset,
|
||||||
height: UIScreen.main.bounds.height * 0.8,
|
height: globalSize.isLandscape ? globalSize.height * 0.8 : globalSize.height * 0.4,
|
||||||
multiplier: 0.3
|
multiplier: 0.3
|
||||||
) {
|
) {
|
||||||
headerView
|
headerView
|
||||||
}
|
}
|
||||||
.toolbar {
|
.size($globalSize)
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
if viewModel.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import SwiftUI
|
||||||
// TODO: seems to redraw view when popped to sometimes?
|
// TODO: seems to redraw view when popped to sometimes?
|
||||||
// - similar to HomeView TODO bug?
|
// - similar to HomeView TODO bug?
|
||||||
// TODO: list view
|
// TODO: list view
|
||||||
|
// TODO: `afterLastDisappear` with `backgroundRefresh`
|
||||||
struct MediaView: View {
|
struct MediaView: View {
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
|
@ -59,6 +60,9 @@ struct MediaView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewModel.send(.refresh)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func errorView(with error: some Error) -> some View {
|
private func errorView(with error: some Error) -> some View {
|
||||||
|
@ -70,18 +74,16 @@ struct MediaView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WrappedView {
|
WrappedView {
|
||||||
Group {
|
switch viewModel.state {
|
||||||
switch viewModel.state {
|
case .content:
|
||||||
case .content:
|
contentView
|
||||||
contentView
|
case let .error(error):
|
||||||
case let .error(error):
|
errorView(with: error)
|
||||||
errorView(with: error)
|
case .initial, .refreshing:
|
||||||
case .initial, .refreshing:
|
DelayedProgressView()
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.transition(.opacity.animation(.linear(duration: 0.1)))
|
|
||||||
}
|
}
|
||||||
|
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.navigationTitle(L10n.allMedia)
|
.navigationTitle(L10n.allMedia)
|
||||||
.topBarTrailing {
|
.topBarTrailing {
|
||||||
|
@ -101,6 +103,7 @@ extension MediaView {
|
||||||
// - differentiate between what media types are Swiftfin only
|
// - differentiate between what media types are Swiftfin only
|
||||||
// which would allow some cleanup
|
// which would allow some cleanup
|
||||||
// - allow server or random view per library?
|
// - allow server or random view per library?
|
||||||
|
// TODO: if local label on image, also needs to be in blurhash placeholder
|
||||||
struct MediaItem: View {
|
struct MediaItem: View {
|
||||||
|
|
||||||
@Default(.Customization.Library.randomImage)
|
@Default(.Customization.Library.randomImage)
|
||||||
|
|
|
@ -16,6 +16,8 @@ import SwiftUI
|
||||||
// other items that don't have a subtitle which requires the entire library to implement
|
// other items that don't have a subtitle which requires the entire library to implement
|
||||||
// subtitle content but that doesn't look appealing. Until a solution arrives grid posters
|
// subtitle content but that doesn't look appealing. Until a solution arrives grid posters
|
||||||
// will not have subtitle content.
|
// will not have subtitle content.
|
||||||
|
// There should be a solution since there are contexts where subtitles are desirable and/or
|
||||||
|
// we can have subtitle content for other items.
|
||||||
|
|
||||||
struct PagingLibraryView<Element: Poster>: View {
|
struct PagingLibraryView<Element: Poster>: View {
|
||||||
|
|
||||||
|
@ -80,6 +82,9 @@ struct PagingLibraryView<Element: Poster>: View {
|
||||||
case .collectionFolder, .folder:
|
case .collectionFolder, .folder:
|
||||||
let viewModel = ItemLibraryViewModel(parent: item, filters: .default)
|
let viewModel = ItemLibraryViewModel(parent: item, filters: .default)
|
||||||
router.route(to: \.library, viewModel)
|
router.route(to: \.library, viewModel)
|
||||||
|
case .person:
|
||||||
|
let viewModel = ItemLibraryViewModel(parent: item)
|
||||||
|
router.route(to: \.library, viewModel)
|
||||||
default:
|
default:
|
||||||
router.route(to: \.item, item)
|
router.route(to: \.item, item)
|
||||||
}
|
}
|
||||||
|
@ -179,32 +184,33 @@ struct PagingLibraryView<Element: Poster>: View {
|
||||||
listItemView(item: item)
|
listItemView(item: item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReachedBottomEdge(offset: 300) {
|
.onReachedTopEdge(offset: .offset(300)) {
|
||||||
viewModel.send(.getNextPage)
|
viewModel.send(.getNextPage)
|
||||||
}
|
}
|
||||||
.proxy(collectionVGridProxy)
|
.proxy(collectionVGridProxy)
|
||||||
|
.refreshable {
|
||||||
|
viewModel.send(.refresh)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: body
|
// MARK: body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WrappedView {
|
WrappedView {
|
||||||
Group {
|
switch viewModel.state {
|
||||||
switch viewModel.state {
|
case .content:
|
||||||
case let .error(error):
|
if viewModel.elements.isEmpty {
|
||||||
errorView(with: error)
|
L10n.noResults.text
|
||||||
case .initial, .refreshing:
|
} else {
|
||||||
ProgressView()
|
contentView
|
||||||
case .gettingNextPage, .content:
|
|
||||||
if viewModel.elements.isEmpty {
|
|
||||||
L10n.noResults.text
|
|
||||||
} else {
|
|
||||||
contentView
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
case let .error(error):
|
||||||
|
errorView(with: error)
|
||||||
|
case .initial, .refreshing:
|
||||||
|
DelayedProgressView()
|
||||||
}
|
}
|
||||||
.transition(.opacity.animation(.linear(duration: 0.2)))
|
|
||||||
}
|
}
|
||||||
|
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.navigationTitle(viewModel.parent?.displayTitle ?? "")
|
.navigationTitle(viewModel.parent?.displayTitle ?? "")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
@ -282,7 +288,7 @@ struct PagingLibraryView<Element: Poster>: View {
|
||||||
}
|
}
|
||||||
.topBarTrailing {
|
.topBarTrailing {
|
||||||
|
|
||||||
if viewModel.state == .gettingNextPage {
|
if viewModel.backgroundStates.contains(.gettingNextPage) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -85,7 +85,7 @@ struct SearchView: View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func itemsSection(
|
private func itemsSection(
|
||||||
title: String,
|
title: String,
|
||||||
keyPath: ReferenceWritableKeyPath<SearchViewModel, [BaseItemDto]>,
|
keyPath: KeyPath<SearchViewModel, [BaseItemDto]>,
|
||||||
posterType: PosterType
|
posterType: PosterType
|
||||||
) -> some View {
|
) -> some View {
|
||||||
PosterHStack(
|
PosterHStack(
|
||||||
|
@ -96,7 +96,8 @@ struct SearchView: View {
|
||||||
.trailing {
|
.trailing {
|
||||||
SeeAllButton()
|
SeeAllButton()
|
||||||
.onSelect {
|
.onSelect {
|
||||||
router.route(to: \.library, .init(viewModel[keyPath: keyPath]))
|
let viewModel = PagingLibraryViewModel(title: title, viewModel[keyPath: keyPath])
|
||||||
|
router.route(to: \.library, viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onSelect(select)
|
.onSelect(select)
|
||||||
|
|
|
@ -12,6 +12,8 @@ import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import VLCUI
|
import VLCUI
|
||||||
|
|
||||||
|
// TODO: figure out why `continuousLeadingEdge` scroll behavior has different
|
||||||
|
// insets than default continuous
|
||||||
extension VideoPlayer.Overlay {
|
extension VideoPlayer.Overlay {
|
||||||
|
|
||||||
struct ChapterOverlay: View {
|
struct ChapterOverlay: View {
|
||||||
|
@ -56,8 +58,7 @@ extension VideoPlayer.Overlay {
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) {
|
if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) {
|
||||||
let index = viewModel.chapters.firstIndex(of: currentChapter)!
|
collectionHStackProxy.scrollTo(element: currentChapter, animated: true)
|
||||||
collectionHStackProxy.scrollTo(index: index)
|
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(L10n.current)
|
Text(L10n.current)
|
||||||
|
@ -80,8 +81,7 @@ extension VideoPlayer.Overlay {
|
||||||
guard newValue == .chapters else { return }
|
guard newValue == .chapters else { return }
|
||||||
|
|
||||||
if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) {
|
if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) {
|
||||||
let index = viewModel.chapters.firstIndex(of: currentChapter)!
|
collectionHStackProxy.scrollTo(element: currentChapter, animated: false)
|
||||||
collectionHStackProxy.scrollTo(index: index, animated: false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue