Item Views to `Stateful` (#997)

This commit is contained in:
Ethan Pippin 2024-04-01 00:48:41 -06:00 committed by GitHub
parent 4ed9e52d1e
commit fd1a87cb02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 2224 additions and 1064 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ?? []
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ?? []
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" */;

View File

@ -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"
} }
}, },
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@
// //
import Defaults import Defaults
import JellyfinAPI
import SwiftUI import SwiftUI
struct CollectionItemView: View { struct CollectionItemView: View {

View File

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

View File

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

View File

@ -64,6 +64,7 @@ extension MovieItemView {
ItemView.AboutView(viewModel: viewModel) ItemView.AboutView(viewModel: viewModel)
} }
.animation(.linear(duration: 0.2), value: viewModel.item)
} }
} }
} }

View File

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

View File

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

View File

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

View File

@ -22,7 +22,9 @@ extension SeriesItemView {
// MARK: Episodes // MARK: Episodes
SeriesEpisodeSelector(viewModel: viewModel) if viewModel.seasons.isNotEmpty {
SeriesEpisodeSelector(viewModel: viewModel)
}
// MARK: Genres // MARK: Genres

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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