diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 4af09d9f..e77af4b3 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -146,6 +146,24 @@ 628B95382670CDAB0091AF3B /* Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFF263B596B003A4E83 /* Model.xcdatamodeld */; }; 628B953A2670CE250091AF3B /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 628B95392670CE250091AF3B /* KeychainSwift */; }; 628B953C2670D2430091AF3B /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* StringExtensions.swift */; }; + 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */; }; + 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; }; + 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */; }; + 62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */; }; + 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */; }; + 62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */; }; + 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */; }; + 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */; }; + 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */; }; + 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */; }; + 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */; }; + 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */; }; + 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */; }; + 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */; }; + 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */; }; + 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */; }; + 62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* DetailItemViewModel.swift */; }; + 62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62E632F2267D54030063E547 /* DetailItemViewModel.swift */; }; 62EC3527267665D8000E9F2D /* MobileVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; }; 62EC3528267665D8000E9F2D /* MobileVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC352B26766675000E9F2D /* ServerEnvironment.swift */; }; @@ -338,6 +356,15 @@ 628B952A2670CABE0091AF3B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 628B95362670CB800091AF3B /* JellyfinWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinWidget.swift; sourceTree = ""; }; 628B953B2670D1FC0091AF3B /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = ""; }; + 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaViewModel.swift; sourceTree = ""; }; + 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchViewModel.swift; sourceTree = ""; }; + 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; + 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieItemViewModel.swift; sourceTree = ""; }; + 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeItemViewModel.swift; sourceTree = ""; }; + 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemViewModel.swift; sourceTree = ""; }; + 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemViewModel.swift; sourceTree = ""; }; + 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterViewModel.swift; sourceTree = ""; }; + 62E632F2267D54030063E547 /* DetailItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailItemViewModel.swift; sourceTree = ""; }; 62EC352B26766675000E9F2D /* ServerEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerEnvironment.swift; sourceTree = ""; }; 62EC352E267666A5000E9F2D /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; @@ -402,6 +429,15 @@ 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */, + 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */, + 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */, + 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */, + 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */, + 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */, + 62E632E8267D3FF50063E547 /* SeasonItemViewModel.swift */, + 62E632EB267D410B0063E547 /* SeriesItemViewModel.swift */, + 62E632EE267D43320063E547 /* LibraryFilterViewModel.swift */, + 62E632F2267D54030063E547 /* DetailItemViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -440,6 +476,7 @@ 535870752669D60C00D05A09 /* Shared */ = { isa = PBXGroup; children = ( + 62E632F1267D53B00063E547 /* Protocols */, 62EC352A26766657000E9F2D /* Singleton */, 532175392671BCED005491E6 /* ViewModels */, 621338912660106C00A81A2A /* Extensions */, @@ -683,6 +720,13 @@ path = WidgetExtension; sourceTree = ""; }; + 62E632F1267D53B00063E547 /* Protocols */ = { + isa = PBXGroup; + children = ( + ); + path = Protocols; + sourceTree = ""; + }; 62EC352A26766657000E9F2D /* Singleton */ = { isa = PBXGroup; children = ( @@ -882,21 +926,30 @@ 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */, 53ABFDDE267974E300886593 /* SplashView.swift in Sources */, 53ABFDE8267974EF00886593 /* SplashViewModel.swift in Sources */, + 62E632DE267D2E170063E547 /* LatestMediaViewModel.swift in Sources */, 536D3D88267C17350004248C /* PublicUserButton.swift in Sources */, + 62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */, + 62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */, 535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */, + 62E632ED267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, + 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, + 62E632F4267D54030063E547 /* DetailItemViewModel.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, + 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, 535870A52669D8AE00D05A09 /* ParallaxHeader.swift in Sources */, 531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */, 535870A72669D8AE00D05A09 /* MultiSelectorView.swift in Sources */, + 62E632DD267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */, 531690E5267ABD5C005D8AB9 /* MainTabView.swift in Sources */, 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */, 53ABFDEE26799DCD00886593 /* ImageView.swift in Sources */, + 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 5358706C2669D21700D05A09 /* PersistenceController.swift in Sources */, 535870AA2669D8AE00D05A09 /* BlurHashDecode.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, @@ -921,8 +974,10 @@ 621338932660107500A81A2A /* StringExtensions.swift in Sources */, 5362E507267D4707000E2F71 /* HeartbeatChannel.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, + 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 5362E506267D4707000E2F71 /* MediaControlChannel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, + 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, 5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */, 5362E500267D4707000E2F71 /* CastClient.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, @@ -937,11 +992,13 @@ 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, 5362E50E267D4707000E2F71 /* CastStatus.swift in Sources */, 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, + 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */, 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */, 5362E513267D4707000E2F71 /* CastMultizoneDevice.swift in Sources */, 625CB5682678B6FB00530A6E /* SplashView.swift in Sources */, 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */, + 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, 5362E50A267D4707000E2F71 /* RequestSink.swift in Sources */, @@ -951,7 +1008,9 @@ 6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */, 5362E503267D4707000E2F71 /* Channelable.swift in Sources */, 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */, + 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, 625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */, + 62E632F3267D54030063E547 /* DetailItemViewModel.swift in Sources */, 5362E50C267D4707000E2F71 /* DeviceDiscoveryChannel.swift in Sources */, 53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */, 5362E50D267D4707000E2F71 /* CastDevice.swift in Sources */, @@ -959,7 +1018,10 @@ 5362E509267D4707000E2F71 /* DeviceConnectionChannel.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, + 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */, + 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, + 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, 5362E512267D4707000E2F71 /* CastMedia.swift in Sources */, 5362E519267D4707000E2F71 /* cast_channel.pb.swift in Sources */, 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */, diff --git a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 77c500d2..b10bc540 100644 --- a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -29,7 +29,7 @@ } }, { - "package": "jellyfin-sdk-swift", + "package": "JellyfinAPI", "repositoryURL": "https://github.com/jellyfin/jellyfin-sdk-swift", "state": { "branch": "main", @@ -38,7 +38,7 @@ } }, { - "package": "keychain-swift", + "package": "KeychainSwift", "repositoryURL": "https://github.com/evgenyneu/keychain-swift", "state": { "branch": null, @@ -83,7 +83,7 @@ } }, { - "package": "swift-protobuf", + "package": "SwiftProtobuf", "repositoryURL": "https://github.com/apple/swift-protobuf.git", "state": { "branch": null, @@ -92,7 +92,7 @@ } }, { - "package": "SwiftUI-Introspect", + "package": "Introspect", "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect", "state": { "branch": null, diff --git a/JellyfinPlayer/EpisodeItemView.swift b/JellyfinPlayer/EpisodeItemView.swift index b1ca2046..413058e5 100644 --- a/JellyfinPlayer/EpisodeItemView.swift +++ b/JellyfinPlayer/EpisodeItemView.swift @@ -11,62 +11,14 @@ import Combine struct EpisodeItemView: View { @StateObject - var tempViewModel = ViewModel() + var viewModel: EpisodeItemViewModel @State private var orientation = UIDeviceOrientation.unknown @Environment(\.horizontalSizeClass) var hSizeClass @Environment(\.verticalSizeClass) var vSizeClass @EnvironmentObject private var playbackInfo: VideoPlayerItem - var item: BaseItemDto - - @State private var settingState: Bool = true - @State private var watched: Bool = false { - didSet { - if !settingState { - if watched == true { - PlaystateAPI.markPlayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { _ in - }) - .store(in: &tempViewModel.cancellables) - } else { - PlaystateAPI.markUnplayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { _ in - }) - .store(in: &tempViewModel.cancellables) - } - } - } - } - - @State - private var favorite: Bool = false { - didSet { - if !settingState { - if favorite == true { - UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { _ in - }) - .store(in: &tempViewModel.cancellables) - } else { - UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { _ in - }) - .store(in: &tempViewModel.cancellables) - } - } - } - } - var portraitHeaderView: some View { - ImageView(src: item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: item.getBackdropImageBlurHash()) + ImageView(src: viewModel.item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: viewModel.item.getBackdropImageBlurHash()) .opacity(0.4) .blur(radius: 2.0) } @@ -74,27 +26,27 @@ struct EpisodeItemView: View { var portraitHeaderOverlayView: some View { VStack(alignment: .leading) { HStack(alignment: .bottom, spacing: 12) { - ImageView(src: item.getSeriesPrimaryImage(maxWidth: 120), bh: item.getSeriesPrimaryImageBlurHash()) + ImageView(src: viewModel.item.getSeriesPrimaryImage(maxWidth: 120), bh: viewModel.item.getSeriesPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) VStack(alignment: .leading) { Spacer() - Text(item.name ?? "").font(.headline) + Text(viewModel.item.name ?? "").font(.headline) .fontWeight(.semibold) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) .offset(y: 5) HStack { - Text(String(item.productionYear ?? 0)).font(.subheadline) + Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - Text(item.getItemRuntime()).font(.subheadline) + Text(viewModel.item.getItemRuntime()).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - if item.officialRating != nil { - Text(item.officialRating!).font(.subheadline) + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) .fontWeight(.semibold) .foregroundColor(.secondary) .lineLimit(1) @@ -109,11 +61,11 @@ struct EpisodeItemView: View { HStack { // Play button Button { - self.playbackInfo.itemToPlay = item + self.playbackInfo.itemToPlay = viewModel.item self.playbackInfo.shouldShowPlayer = true } label: { HStack { - Text(item.getItemProgressString() == "" ? "Play" : "\(item.getItemProgressString()) left") + Text(viewModel.item.getItemProgressString() == "" ? "Play" : "\(viewModel.item.getItemProgressString()) left") .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) } @@ -125,19 +77,21 @@ struct EpisodeItemView: View { Spacer() HStack { Button { - favorite.toggle() + viewModel.updateFavoriteState() } label: { - if !favorite { - Image(systemName: "heart").foregroundColor(Color.primary).font(.system(size: 20)) - } else { + if viewModel.isFavorited { Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) .font(.system(size: 20)) + } else { + Image(systemName: "heart").foregroundColor(Color.primary) + .font(.system(size: 20)) } } + .disabled(viewModel.isLoading) Button { - watched.toggle() + viewModel.updateWatchState() } label: { - if watched { + if viewModel.isWatched { Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary) .font(.system(size: 20)) } else { @@ -145,6 +99,7 @@ struct EpisodeItemView: View { .font(.system(size: 20)) } } + .disabled(viewModel.isLoading) } }.padding(.top, 8) } @@ -160,21 +115,21 @@ struct EpisodeItemView: View { Spacer() .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) - if !(item.taglines ?? []).isEmpty { - Text(item.taglines!.first!).font(.body).italic().padding(.top, 7) + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) .padding(.trailing, 16) } - Text(item.overview ?? "").font(.footnote).padding(.top, 3) + Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) .padding(.trailing, 16) - if !(item.genreItems ?? []).isEmpty { + if !(viewModel.item.genreItems ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(item.genreItems!, id: \.id) { genre in + ForEach(viewModel.item.genreItems!, id: \.id) { genre in NavigationLink(destination: LazyView { - LibraryView(withGenre: genre) + LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") }) { Text(genre.name ?? "").font(.footnote) } @@ -182,16 +137,16 @@ struct EpisodeItemView: View { }.padding(.leading, 16).padding(.trailing, 16) } } - if !(item.people ?? []).isEmpty { + if !(viewModel.item.people ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { VStack { Spacer().frame(height: 8) HStack { Spacer().frame(width: 16) - ForEach(item.people!, id: \.self) { person in + ForEach(viewModel.item.people!, id: \.self) { person in if person.type! == "Actor" { NavigationLink(destination: LazyView { - LibraryView(withPerson: person) + LibraryView(viewModel: .init(person: person), title: person.name ?? "") }) { VStack { ImageView(src: person.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: person.getBlurHash()) @@ -213,13 +168,13 @@ struct EpisodeItemView: View { } }.padding(.top, -3) } - if !(item.studios ?? []).isEmpty { + if !(viewModel.item.studios ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(item.studios!, id: \.id) { studio in + ForEach(viewModel.item.studios!, id: \.id) { studio in NavigationLink(destination: LazyView { - LibraryView(withStudio: studio) + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") }) { Text(studio.name ?? "").font(.footnote) } @@ -233,7 +188,7 @@ struct EpisodeItemView: View { } else { GeometryReader { geometry in ZStack { - ImageView(src: item.getBackdropImage(maxWidth: 200), bh: item.getBackdropImageBlurHash()) + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), bh: viewModel.item.getBackdropImageBlurHash()) .opacity(0.3) .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) @@ -241,16 +196,16 @@ struct EpisodeItemView: View { .blur(radius: 4) HStack { VStack { - ImageView(src: item.getSeriesPrimaryImage(maxWidth: 120), bh: item.getSeriesPrimaryImageBlurHash()) + ImageView(src: viewModel.item.getSeriesPrimaryImage(maxWidth: 120), bh: viewModel.item.getSeriesPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) Spacer().frame(height: 15) Button { - self.playbackInfo.itemToPlay = item + self.playbackInfo.itemToPlay = viewModel.item self.playbackInfo.shouldShowPlayer = true } label: { HStack { - Text(item.getItemProgressString() == "" ? "Play" : "\(item.getItemProgressString()) left") + Text(viewModel.item.getItemProgressString() == "" ? "Play" : "\(viewModel.item.getItemProgressString()) left") .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) } @@ -265,23 +220,23 @@ struct EpisodeItemView: View { VStack(alignment: .leading) { HStack { VStack(alignment: .leading) { - Text(item.name ?? "").font(.headline) + Text(viewModel.item.name ?? "").font(.headline) .fontWeight(.semibold) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) .offset(x: 14, y: 0) Spacer().frame(height: 1) HStack { - Text(String(item.productionYear ?? 0)).font(.subheadline) + Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - Text(item.getItemRuntime()).font(.subheadline) + Text(viewModel.item.getItemRuntime()).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - if item.officialRating != nil { - Text(item.officialRating!).font(.subheadline) + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) .fontWeight(.semibold) .foregroundColor(.secondary) .lineLimit(1) @@ -289,10 +244,10 @@ struct EpisodeItemView: View { .overlay(RoundedRectangle(cornerRadius: 2) .stroke(Color.secondary, lineWidth: 1)) } - if item.communityRating != nil { + if viewModel.item.communityRating != nil { HStack { Image(systemName: "star").foregroundColor(.secondary) - Text(String(item.communityRating!)).font(.subheadline) + Text(String(viewModel.item.communityRating!)).font(.subheadline) .fontWeight(.semibold) .foregroundColor(.secondary) .lineLimit(1) @@ -306,20 +261,21 @@ struct EpisodeItemView: View { Spacer() HStack { Button { - favorite.toggle() + viewModel.updateFavoriteState() } label: { - if !favorite { - Image(systemName: "heart").foregroundColor(Color.primary) + if viewModel.isFavorited { + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) .font(.system(size: 20)) } else { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + Image(systemName: "heart").foregroundColor(Color.primary) .font(.system(size: 20)) } } + .disabled(viewModel.isLoading) Button { - watched.toggle() + viewModel.updateWatchState() } label: { - if watched { + if viewModel.isWatched { Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary) .font(.system(size: 20)) } else { @@ -327,23 +283,24 @@ struct EpisodeItemView: View { .font(.system(size: 20)) } } + .disabled(viewModel.isLoading) } }.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if !(item.taglines ?? []).isEmpty { - Text(item.taglines!.first!).font(.body).italic().padding(.top, 3) + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 3) .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - Text(item.overview ?? "").font(.footnote).padding(.top, 3) + Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if !(item.genreItems ?? []).isEmpty { + if !(viewModel.item.genreItems ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(item.genreItems!, id: \.id) { genre in + ForEach(viewModel.item.genreItems!, id: \.id) { genre in NavigationLink(destination: LazyView { - LibraryView(withGenre: genre) + LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") }) { Text(genre.name ?? "").font(.footnote) } @@ -353,16 +310,16 @@ struct EpisodeItemView: View { .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } } - if !(item.people ?? []).isEmpty { + if !(viewModel.item.people ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { VStack { Spacer().frame(height: 8) HStack { Spacer().frame(width: 16) - ForEach(item.people!, id: \.self) { person in + ForEach(viewModel.item.people!, id: \.self) { person in if person.type! == "Actor" { NavigationLink(destination: LazyView { - LibraryView(withPerson: person) + LibraryView(viewModel: .init(person: person), title: person.name ?? "") }) { VStack { ImageView(src: person.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: person.getBlurHash()) @@ -384,13 +341,13 @@ struct EpisodeItemView: View { } }.padding(.top, -3) } - if !(item.studios ?? []).isEmpty { + if !(viewModel.item.studios ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(item.studios!, id: \.id) { studio in + ForEach(viewModel.item.studios!, id: \.id) { studio in NavigationLink(destination: LazyView { - LibraryView(withStudio: studio) + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") }) { Text(studio.name ?? "").font(.footnote) } @@ -409,15 +366,10 @@ struct EpisodeItemView: View { } } } - .onAppear(perform: { - favorite = item.userData?.isFavorite ?? false - watched = item.userData?.played ?? false - settingState = false - }) .onRotate(perform: { orientation in self.orientation = orientation }) .navigationBarTitleDisplayMode(.inline) - .navigationTitle("\(item.seriesName ?? "") - S\(String(item.parentIndexNumber ?? 0)):E\(String(item.indexNumber ?? 0))") + .navigationTitle("\(viewModel.item.seriesName ?? "") - S\(String(viewModel.item.parentIndexNumber ?? 0)):E\(String(viewModel.item.indexNumber ?? 0))") } } diff --git a/JellyfinPlayer/HomeView.swift b/JellyfinPlayer/HomeView.swift index b01803cb..779a7389 100644 --- a/JellyfinPlayer/HomeView.swift +++ b/JellyfinPlayer/HomeView.swift @@ -44,8 +44,7 @@ struct HomeView: View { .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) Spacer() NavigationLink(destination: LazyView { - LibraryView(usingParentID: libraryID, - title: library?.name ?? "", usingFilters: viewModel.recentFilterSet) + LibraryView(viewModel: .init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? "") }) { HStack { Text("See All").font(.subheadline).fontWeight(.bold) @@ -53,7 +52,7 @@ struct HomeView: View { } } }.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - LatestMediaView(usingParentID: libraryID) + LatestMediaView(viewModel: .init(libraryID: libraryID)) }.padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) } } diff --git a/JellyfinPlayer/ItemView.swift b/JellyfinPlayer/ItemView.swift index 4db22550..b27f6679 100644 --- a/JellyfinPlayer/ItemView.swift +++ b/JellyfinPlayer/ItemView.swift @@ -39,13 +39,13 @@ struct ItemView: View { } VStack { if item.type == "Movie" { - MovieItemView(item: item) + MovieItemView(viewModel: .init(item: item)) } else if item.type == "Season" { - SeasonItemView(item: item) + SeasonItemView(viewModel: .init(item: item)) } else if item.type == "Series" { - SeriesItemView(item: item) + SeriesItemView(viewModel: .init(item: item)) } else if item.type == "Episode" { - EpisodeItemView(item: item) + EpisodeItemView(viewModel: .init(item: item)) } else { Text("Type: \(item.type ?? "") not implemented yet :(") } diff --git a/JellyfinPlayer/LatestMediaView.swift b/JellyfinPlayer/LatestMediaView.swift index c1acee9f..b4760031 100644 --- a/JellyfinPlayer/LatestMediaView.swift +++ b/JellyfinPlayer/LatestMediaView.swift @@ -5,44 +5,20 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI -import JellyfinAPI import Combine +import JellyfinAPI +import SwiftUI struct LatestMediaView: View { - @StateObject - var tempViewModel = ViewModel() - @State var items: [BaseItemDto] = [] - private var library_id: String = "" - @State private var viewDidLoad: Bool = false - - init(usingParentID: String) { - library_id = usingParentID - } - - func onAppear() { - if viewDidLoad == true { - return - } - viewDidLoad = true - - DispatchQueue.global(qos: .userInitiated).async { - UserLibraryAPI.getLatestMedia(userId: SessionManager.current.user.user_id!, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true, limit: 12) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { response in - items = response - }) - .store(in: &tempViewModel.cancellables) - } - } + var viewModel: LatestMediaViewModel var body: some View { - ScrollView(.horizontal, showsIndicators: false) { + ScrollView(.horizontal, showsIndicators: false) { + ZStack { LazyHStack { Spacer().frame(width: 16) - ForEach(items, id: \.id) { item in + ForEach(viewModel.items, id: \.id) { item in if item.type == "Series" || item.type == "Movie" { NavigationLink(destination: ItemView(item: item)) { VStack(alignment: .leading) { @@ -69,10 +45,13 @@ struct LatestMediaView: View { } } } + if viewModel.isLoading { + ProgressView() + } } - .frame(height: 190) } - .onAppear(perform: onAppear) - .padding(EdgeInsets(top: -2, leading: 0, bottom: 0, trailing: 0)).frame(height: 190) + .frame(height: 190) + } + .padding(EdgeInsets(top: -2, leading: 0, bottom: 0, trailing: 0)).frame(height: 190) } } diff --git a/JellyfinPlayer/LibraryFilterView.swift b/JellyfinPlayer/LibraryFilterView.swift index 335a93f3..2720692d 100644 --- a/JellyfinPlayer/LibraryFilterView.swift +++ b/JellyfinPlayer/LibraryFilterView.swift @@ -5,75 +5,80 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI import JellyfinAPI +import SwiftUI struct LibraryFilterView: View { - @Binding var filter: LibraryFilters + @Environment(\.presentationMode) + var presentationMode + @Binding + var filters: LibraryFilters + + @StateObject + var viewModel: LibraryFilterViewModel + + init(filters: Binding, enabledFilterType: [FilterType]) { + _filters = filters + _viewModel = StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType)) + } var body: some View { - EmptyView() - /* NavigationView { - LoadingView(isShowing: $isLoading) { + ZStack { Form { - Toggle("Only show unplayed items", isOn: $onlyUnplayed) - .onChange(of: onlyUnplayed) { value in - if value { - filter.filterTypes.append(.isUnplayed) - } else { - filter.filterTypes.removeAll { $0 == .isUnplayed } - } - } - MultiSelector(label: "Genres", - options: allGenres, - optionToString: { $0.name }, - selected: $selectedGenres) - .onChange(of: selectedGenres) { genres in - filter.genres = genres.map(\.id) - } - MultiSelector(label: "Parental Ratings", - options: allRatings, - optionToString: { $0.name }, - selected: $selectedRatings) - .onChange(of: selectedRatings) { ratings in - filter.officialRatings = ratings.map(\.id) - } - - Section(header: Text("Sort settings")) { - Picker("Sort by", selection: $sortBySelection) { - Text("Name").tag("SortName") - Text("Date Added").tag("DateCreated") - Text("Date Played").tag("DatePlayed") - Text("Date Released").tag("PremiereDate") - Text("Runtime").tag("Runtime") - }.onChange(of: sortBySelection) { value in - guard let sort = SortType(rawValue: value) else { return } - filter.sort = sort - } - Picker("Sort order", selection: $sortOrder) { - Text("Ascending").tag("Ascending") - Text("Descending").tag("Descending") - }.onChange(of: sortOrder) { order in - guard let asc = ASC(rawValue: order) else { return } - filter.asc = asc - } + if viewModel.enabledFilterType.contains(.genre) { + MultiSelector(label: "Genres", + options: viewModel.possibleGenres, + optionToString: { $0.name ?? "" }, + selected: $viewModel.modifyedFilters.withGenres) + } + if viewModel.enabledFilterType.contains(.filter) { + MultiSelector(label: "Filters", + options: viewModel.possibleItemFilters, + optionToString: { $0.localized }, + selected: $viewModel.modifyedFilters.filters) + } + if viewModel.enabledFilterType.contains(.tag) { + MultiSelector(label: "Tags", + options: viewModel.possibleTags, + optionToString: { $0 }, + selected: $viewModel.modifyedFilters.tags) + } + if viewModel.enabledFilterType.contains(.sortBy) { + MultiSelector(label: "Sort by", + options: viewModel.possibleSortBys, + optionToString: { $0.localized }, + selected: $viewModel.modifyedFilters.sortBy) + } + if viewModel.enabledFilterType.contains(.sortOrder) { + MultiSelector(label: "Sort Order", + options: viewModel.possibleSortOrders, + optionToString: { $0.localized }, + selected: $viewModel.modifyedFilters.sortOrder) } } - }.onAppear(perform: onAppear) - .navigationBarTitle("Filters", displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - presentationMode.wrappedValue.dismiss() - } label: { - HStack { - Text("Back").font(.callout) - } - } + if viewModel.isLoading { + ProgressView() + } + } + .navigationBarTitle("Filters", displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + presentationMode.wrappedValue.dismiss() + } label: { + Image(systemName: "xmark") } } + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + self.filters = viewModel.modifyedFilters + presentationMode.wrappedValue.dismiss() + } label: { + Text("Apply") + } + } + } } - */ } } diff --git a/JellyfinPlayer/LibraryListView.swift b/JellyfinPlayer/LibraryListView.swift index f916894a..0fecac3e 100644 --- a/JellyfinPlayer/LibraryListView.swift +++ b/JellyfinPlayer/LibraryListView.swift @@ -17,7 +17,7 @@ struct LibraryListView: View { switch library.id { case "favorites": NavigationLink(destination: LazyView { - LibraryView(usingParentID: "", title: library.name ?? "", usingFilters: viewModel.withFavorites) + LibraryView(viewModel: .init(filters: viewModel.withFavorites), title: library.name ?? "") }) { Text(library.name ?? "") } @@ -29,7 +29,7 @@ struct LibraryListView: View { } default: NavigationLink(destination: LazyView { - LibraryView(usingParentID: library.id ?? "", title: library.name ?? "") + LibraryView(viewModel: .init(parentID: library.id), title: library.name ?? "") }) { Text(library.name ?? "") } @@ -39,7 +39,7 @@ struct LibraryListView: View { .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { NavigationLink(destination: LazyView { - LibrarySearchView(usingParentID: "") + LibrarySearchView(viewModel: .init(parentID: nil)) }) { Image(systemName: "magnifyingglass") } diff --git a/JellyfinPlayer/LibrarySearchView.swift b/JellyfinPlayer/LibrarySearchView.swift index 5c28d8f7..454c0cea 100644 --- a/JellyfinPlayer/LibrarySearchView.swift +++ b/JellyfinPlayer/LibrarySearchView.swift @@ -5,67 +5,33 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI -import JellyfinAPI import Combine +import JellyfinAPI +import SwiftUI struct LibrarySearchView: View { - @StateObject - var tempViewModel = ViewModel() - @State private var items: [BaseItemDto] = [] - @State private var searchQuery: String = "" - @State private var isLoading: Bool = false - private var usingParentID: String = "" - @State private var lastSearchTime: Double = CACurrentMediaTime() - - init(usingParentID: String) { - self.usingParentID = usingParentID - } - - func onAppear() { - recalcTracks() - requestSearch(query: "") - } - - func requestSearch(query: String) { - isLoading = true - DispatchQueue.global(qos: .userInitiated).async { - ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, limit: 60, recursive: true, searchTerm: query, sortOrder: [.ascending], parentId: (usingParentID != "" ? usingParentID : nil), fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: ["Movie", "Series"], sortBy: ["SortName"], enableUserData: true, enableImages: true) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { response in - items = response.items ?? [] - isLoading = false - }) - .store(in: &tempViewModel.cancellables) - } - } + var viewModel: LibrarySearchViewModel // MARK: tracks for grid - @State private var tracks: [GridItem] = [] + + @State + private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + func recalcTracks() { - let trkCnt = Int(floor(UIScreen.main.bounds.size.width / 125)) - tracks = [] - for _ in 0 ..< trkCnt { - tracks.append(GridItem(.flexible())) - } + tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) } var body: some View { VStack { Spacer().frame(height: 6) - SearchBar(text: $searchQuery) - if isLoading == true { - Spacer() - ProgressView() - Spacer() - } else { - if !items.isEmpty { - ScrollView(.vertical) { + SearchBar(text: $viewModel.searchQuery) + ZStack { + ScrollView(.vertical) { + if !viewModel.items.isEmpty { Spacer().frame(height: 16) LazyVGrid(columns: tracks) { - ForEach(items, id: \.id) { item in + ForEach(viewModel.items, id: \.id) { item in NavigationLink(destination: ItemView(item: item)) { VStack(alignment: .leading) { ImageView(src: item.getPrimaryImage(maxWidth: 100), bh: item.getPrimaryImageBlurHash()) @@ -87,26 +53,20 @@ struct LibrarySearchView: View { }.frame(width: 100) } } + Spacer().frame(height: 16) } - Spacer().frame(height: 16) - .onRotate { _ in + .onRotate { _ in recalcTracks() } + } else if !viewModel.isLoading { + Text("No results :(") } - } else { - Text("No results :(") + } + if viewModel.isLoading { + ProgressView() } } } - .onAppear(perform: onAppear) .navigationBarTitle("Search", displayMode: .inline) - .onChange(of: searchQuery) { query in - if CACurrentMediaTime() - lastSearchTime > 0.5 { - lastSearchTime = CACurrentMediaTime() - requestSearch(query: query) - } - } } } - -// stream NM5 by nicki! diff --git a/JellyfinPlayer/LibraryView.swift b/JellyfinPlayer/LibraryView.swift index de9b71a0..e6ffec52 100644 --- a/JellyfinPlayer/LibraryView.swift +++ b/JellyfinPlayer/LibraryView.swift @@ -6,190 +6,132 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import SwiftUI -import NukeUI -import JellyfinAPI import Combine +import JellyfinAPI +import NukeUI +import SwiftUI struct LibraryView: View { - @StateObject - var tempViewModel = ViewModel() - @State private var items: [BaseItemDto] = [] - @State private var isLoading: Bool = false - - private var usingParentID: String = "" - private var title: String = "" - private var filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: ["SortName"]) - private var personId: String = "" - private var genre: String = "" - private var studio: String = "" - - @State private var totalPages: Int = 0 - @State private var currentPage: Int = 0 - @State private var isSearching: String? = "" - @State private var viewDidLoad: Bool = false - - init(usingParentID: String, title: String) { - self.usingParentID = usingParentID - self.title = title - } - - init(usingParentID: String, title: String, usingFilters: LibraryFilters) { - self.usingParentID = usingParentID - self.title = title - self.filters = usingFilters - } - - init(withPerson: BaseItemPerson) { - self.usingParentID = "" - self.title = withPerson.name ?? "" - self.personId = withPerson.id! - } - - init(withGenre: NameGuidPair) { - self.usingParentID = "" - self.title = withGenre.name ?? "" - self.genre = withGenre.id! - } - - init(withStudio: NameGuidPair) { - self.usingParentID = "" - self.title = withStudio.name ?? "" - self.studio = withStudio.id! - } - - func onAppear() { - recalcTracks() - - if viewDidLoad { - return - } - - isLoading = true - items = [] - - DispatchQueue.global(qos: .userInitiated).async { - ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, startIndex: currentPage * 100, limit: 100, recursive: true, searchTerm: nil, sortOrder: filters.sortOrder, parentId: (usingParentID != "" ? usingParentID : nil), fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: ["Movie", "Series"], filters: filters.filters, sortBy: filters.sortBy, enableUserData: true, personIds: (personId == "" ? nil : [personId]), studioIds: (studio == "" ? nil : [studio]), genreIds: (genre == "" ? nil : [genre]), enableImages: true) - .sink(receiveCompletion: { completion in - print(completion) - isLoading = false - }, receiveValue: { response in - let x = ceil(Double(response.totalRecordCount!) / 100.0) - totalPages = Int(x) - items = response.items ?? [] - isLoading = false - viewDidLoad = true - }) - .store(in: &tempViewModel.cancellables) - } - } + var viewModel: LibraryViewModel + var title: String // MARK: tracks for grid - @State private var tracks: [GridItem] = [] + + @State + var isShowingSearchView = false + @State + var isShowingFilterView = false + + @State + private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + func recalcTracks() { - let trkCnt = Int(floor(UIScreen.main.bounds.size.width / 125)) - _tracks.wrappedValue = [] - for _ in 0 ..< trkCnt { - _tracks.wrappedValue.append(GridItem(.flexible())) - } + tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) } var body: some View { - ZStack { - if isLoading == true { + Group { + if viewModel.isLoading == true { ProgressView() - } else { - if !items.isEmpty { - VStack { - ScrollView(.vertical) { - Spacer().frame(height: 16) - LazyVGrid(columns: tracks) { - ForEach(items, id: \.id) { item in - NavigationLink(destination: ItemView(item: item)) { - VStack(alignment: .leading) { - ImageView(src: item.getPrimaryImage(maxWidth: 100), bh: item.getPrimaryImageBlurHash()) - .frame(width: 100, height: 150) - .cornerRadius(10) - Text(item.name ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - if item.productionYear != nil { - Text(String(item.productionYear!)) - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } else { - Text(item.type ?? "") - } - }.frame(width: 100) - } - } - }.onRotate { _ in - recalcTracks() - } - if totalPages > 1 { - HStack { - Spacer() - HStack { - Button { - currentPage = currentPage - 1 - onAppear() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 25)) - }.disabled(currentPage == 0) - Text("Page \(String(currentPage+1)) of \(String(totalPages))") - .font(.headline) + } else if !viewModel.items.isEmpty { + VStack { + ScrollView(.vertical) { + Spacer().frame(height: 16) + LazyVGrid(columns: tracks) { + ForEach(viewModel.items, id: \.id) { item in + NavigationLink(destination: ItemView(item: item)) { + VStack(alignment: .leading) { + ImageView(src: item.getPrimaryImage(maxWidth: 100), bh: item.getPrimaryImageBlurHash()) + .frame(width: 100, height: 150) + .cornerRadius(10) + Text(item.name ?? "") + .font(.caption) .fontWeight(.semibold) - Button { - currentPage = currentPage + 1 - onAppear() - } label: { - Image(systemName: "chevron.right") - .font(.system(size: 25)) - }.disabled(currentPage > totalPages - 1) - } - Spacer() + .foregroundColor(.primary) + .lineLimit(1) + if item.productionYear != nil { + Text(String(item.productionYear!)) + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } else { + Text(item.type ?? "") + } + }.frame(width: 100) } } - Spacer().frame(height: 16) + }.onRotate { _ in + recalcTracks() } + if viewModel.hasNextPage || viewModel.hasPreviousPage { + HStack { + Spacer() + HStack { + Button { + viewModel.requestPreviousPage() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 25)) + }.disabled(viewModel.hasPreviousPage) + Text("Page \(String(viewModel.currentPage + 1)) of \(String(viewModel.totalPages))") + .font(.headline) + .fontWeight(.semibold) + Button { + viewModel.requestNextPage() + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 25)) + }.disabled(viewModel.hasNextPage) + } + Spacer() + } + } + Spacer().frame(height: 16) } - } else { - Text("No results.") } + } else { + Text("No results.") } } - .onAppear(perform: onAppear) .navigationBarTitle(title, displayMode: .inline) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - if currentPage > 0 { + if viewModel.hasPreviousPage { Button { - currentPage = currentPage - 1 - onAppear() + viewModel.requestPreviousPage() } label: { Image(systemName: "chevron.left") } } - if currentPage < totalPages - 1 { + if viewModel.hasNextPage { Button { - currentPage = currentPage + 1 - onAppear() + viewModel.requestNextPage() } label: { Image(systemName: "chevron.right") } } - if usingParentID != "" { - NavigationLink(destination: LibrarySearchView(usingParentID: usingParentID)) { - Image(systemName: "magnifyingglass") - } + Button(action: { + isShowingFilterView = true + }) { + Image(systemName: "line.horizontal.3.decrease.circle") + } + Button(action: { + isShowingSearchView = true + }) { + Image(systemName: "magnifyingglass") } } } + .sheet(isPresented: $isShowingFilterView) { + LibraryFilterView(filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType) + } + .background( + NavigationLink(destination: LibrarySearchView(viewModel: .init(parentID: viewModel.parentID)), + isActive: $isShowingSearchView) { + EmptyView() + } + ) } } diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift index 73248081..a5743796 100644 --- a/JellyfinPlayer/MovieItemView.swift +++ b/JellyfinPlayer/MovieItemView.swift @@ -11,7 +11,7 @@ import SwiftUI struct MovieItemView: View { @StateObject - var tempViewModel = ViewModel() + var viewModel: MovieItemViewModel @State private var orientation = UIDeviceOrientation.unknown @Environment(\.horizontalSizeClass) @@ -21,60 +21,9 @@ struct MovieItemView: View { @EnvironmentObject private var playbackInfo: VideoPlayerItem - var item: BaseItemDto - - @State - private var settingState: Bool = true - @State - private var watched: Bool = false { - didSet { - if !settingState { - if watched == true { - PlaystateAPI.markPlayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { _ in - }) - .store(in: &tempViewModel.cancellables) - } else { - PlaystateAPI.markUnplayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { _ in - }) - .store(in: &tempViewModel.cancellables) - } - } - } - } - - @State - private var favorite: Bool = false { - didSet { - if !settingState { - if favorite == true { - UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { _ in - }) - .store(in: &tempViewModel.cancellables) - } else { - UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { _ in - }) - .store(in: &tempViewModel.cancellables) - } - } - } - } - var portraitHeaderView: some View { - ImageView(src: item - .getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), - bh: item.getBackdropImageBlurHash()) + ImageView(src: viewModel.item.getBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), + bh: viewModel.item.getBackdropImageBlurHash()) .opacity(0.4) .blur(radius: 2.0) } @@ -82,29 +31,29 @@ struct MovieItemView: View { var portraitHeaderOverlayView: some View { VStack(alignment: .leading) { HStack(alignment: .bottom, spacing: 12) { - ImageView(src: item.getPrimaryImage(maxWidth: 120)) + ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120)) .frame(width: 120, height: 180) .cornerRadius(10) VStack(alignment: .leading) { Spacer() - Text(item.name ?? "").font(.headline) + Text(viewModel.item.name ?? "").font(.headline) .fontWeight(.semibold) .foregroundColor(.primary) .lineLimit(1) .offset(y: 5) HStack { - if item.productionYear != nil { - Text(String(item.productionYear ?? 0)).font(.subheadline) + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) } - Text(item.getItemRuntime()).font(.subheadline) + Text(viewModel.item.getItemRuntime()).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - if item.officialRating != nil { - Text(item.officialRating!).font(.subheadline) + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) .fontWeight(.semibold) .foregroundColor(.secondary) .lineLimit(1) @@ -119,11 +68,11 @@ struct MovieItemView: View { HStack { // Play button Button { - self.playbackInfo.itemToPlay = item + self.playbackInfo.itemToPlay = viewModel.item self.playbackInfo.shouldShowPlayer = true } label: { HStack { - Text(item.getItemProgressString() == "" ? "Play" : "\(item.getItemProgressString()) left") + Text(viewModel.item.getItemProgressString() == "" ? "Play" : "\(viewModel.item.getItemProgressString()) left") .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) } @@ -135,19 +84,21 @@ struct MovieItemView: View { Spacer() HStack { Button { - favorite.toggle() + viewModel.updateFavoriteState() } label: { - if !favorite { - Image(systemName: "heart").foregroundColor(Color.primary).font(.system(size: 20)) - } else { + if viewModel.isFavorited { Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) .font(.system(size: 20)) + } else { + Image(systemName: "heart").foregroundColor(Color.primary) + .font(.system(size: 20)) } } + .disabled(viewModel.isLoading) Button { - watched.toggle() + viewModel.updateWatchState() } label: { - if watched { + if viewModel.isWatched { Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary) .font(.system(size: 20)) } else { @@ -155,6 +106,7 @@ struct MovieItemView: View { .font(.system(size: 20)) } } + .disabled(viewModel.isLoading) } }.padding(.top, 8) } @@ -173,21 +125,21 @@ struct MovieItemView: View { Spacer() .frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24) - if !(item.taglines ?? []).isEmpty { - Text(item.taglines!.first!).font(.body).italic().padding(.top, 7) + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) .padding(.trailing, 16) } - Text(item.overview ?? "").font(.footnote).padding(.top, 3) + Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) .padding(.trailing, 16) - if !(item.genreItems ?? []).isEmpty { + if !(viewModel.item.genreItems ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(item.genreItems!, id: \.id) { genre in + ForEach(viewModel.item.genreItems!, id: \.id) { genre in NavigationLink(destination: LazyView { - LibraryView(withGenre: genre) + LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") }) { Text(genre.name ?? "").font(.footnote) } @@ -195,16 +147,16 @@ struct MovieItemView: View { }.padding(.leading, 16).padding(.trailing, 16) } } - if !(item.people ?? []).isEmpty { + if !(viewModel.item.people ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { VStack { Spacer().frame(height: 8) HStack { Spacer().frame(width: 16) - ForEach(item.people!, id: \.self) { person in + ForEach(viewModel.item.people!, id: \.self) { person in if person.type! == "Actor" { NavigationLink(destination: LazyView { - LibraryView(withPerson: person) + LibraryView(viewModel: .init(person: person), title: person.name ?? "") }) { VStack { ImageView(src: person @@ -228,13 +180,14 @@ struct MovieItemView: View { } }.padding(.top, -3) } - if !(item.studios ?? []).isEmpty { + if !(viewModel.item.studios ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(item.studios!, id: \.id) { studio in + ForEach(viewModel.item.studios!, id: \.id) { studio in NavigationLink(destination: LazyView { - LibraryView(withStudio: studio) + + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") }) { Text(studio.name ?? "").font(.footnote) } @@ -248,8 +201,8 @@ struct MovieItemView: View { } else { GeometryReader { geometry in ZStack { - ImageView(src: item.getBackdropImage(maxWidth: 200), - bh: item.getBackdropImageBlurHash()) + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), + bh: viewModel.item.getBackdropImageBlurHash()) .opacity(0.3) .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) @@ -257,17 +210,17 @@ struct MovieItemView: View { .blur(radius: 4) HStack { VStack { - ImageView(src: item.getPrimaryImage(maxWidth: 120), - bh: item.getPrimaryImageBlurHash()) + ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), + bh: viewModel.item.getPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) Spacer().frame(height: 15) Button { - self.playbackInfo.itemToPlay = item + self.playbackInfo.itemToPlay = viewModel.item self.playbackInfo.shouldShowPlayer = true } label: { HStack { - Text(item.getItemProgressString() == "" ? "Play" : "\(item.getItemProgressString()) left") + Text(viewModel.item.getItemProgressString() == "" ? "Play" : "\(viewModel.item.getItemProgressString()) left") .foregroundColor(Color.white).font(.callout).fontWeight(.semibold) Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) } @@ -282,25 +235,25 @@ struct MovieItemView: View { VStack(alignment: .leading) { HStack { VStack(alignment: .leading) { - Text(item.name ?? "").font(.headline) + Text(viewModel.item.name ?? "").font(.headline) .fontWeight(.semibold) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) .offset(x: 14, y: 0) Spacer().frame(height: 1) HStack { - if item.productionYear != nil { - Text(String(item.productionYear ?? 0)).font(.subheadline) + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear ?? 0)).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) } - Text(item.getItemRuntime()).font(.subheadline) + Text(viewModel.item.getItemRuntime()).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - if item.officialRating != nil { - Text(item.officialRating!).font(.subheadline) + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) .fontWeight(.semibold) .foregroundColor(.secondary) .lineLimit(1) @@ -308,10 +261,10 @@ struct MovieItemView: View { .overlay(RoundedRectangle(cornerRadius: 2) .stroke(Color.secondary, lineWidth: 1)) } - if item.communityRating != nil { + if viewModel.item.communityRating != nil { HStack { Image(systemName: "star").foregroundColor(.secondary) - Text(String(item.communityRating!)).font(.subheadline) + Text(String(viewModel.item.communityRating!)).font(.subheadline) .fontWeight(.semibold) .foregroundColor(.secondary) .lineLimit(1) @@ -325,20 +278,21 @@ struct MovieItemView: View { Spacer() HStack { Button { - favorite.toggle() + viewModel.updateFavoriteState() } label: { - if !favorite { - Image(systemName: "heart").foregroundColor(Color.primary) + if viewModel.isFavorited { + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) .font(.system(size: 20)) } else { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + Image(systemName: "heart").foregroundColor(Color.primary) .font(.system(size: 20)) } } + .disabled(viewModel.isLoading) Button { - watched.toggle() + viewModel.updateWatchState() } label: { - if watched { + if viewModel.isWatched { Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary) .font(.system(size: 20)) } else { @@ -346,23 +300,24 @@ struct MovieItemView: View { .font(.system(size: 20)) } } + .disabled(viewModel.isLoading) } }.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if !(item.taglines ?? []).isEmpty { - Text(item.taglines!.first!).font(.body).italic().padding(.top, 3) + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 3) .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - Text(item.overview ?? "").font(.footnote).padding(.top, 3) + Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if !(item.genreItems ?? []).isEmpty { + if !(viewModel.item.genreItems ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(item.genreItems!, id: \.id) { genre in + ForEach(viewModel.item.genreItems!, id: \.id) { genre in NavigationLink(destination: LazyView { - LibraryView(withGenre: genre) + LibraryView(viewModel: .init(genre: genre), title: genre.name ?? "") }) { Text(genre.name ?? "").font(.footnote) } @@ -372,16 +327,16 @@ struct MovieItemView: View { .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } } - if !(item.people ?? []).isEmpty { + if !(viewModel.item.people ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { VStack { Spacer().frame(height: 8) HStack { Spacer().frame(width: 16) - ForEach(item.people!, id: \.self) { person in + ForEach(viewModel.item.people!, id: \.self) { person in if person.type! == "Actor" { NavigationLink(destination: LazyView { - LibraryView(withPerson: person) + LibraryView(viewModel: .init(person: person), title: person.name ?? "") }) { VStack { ImageView(src: person @@ -407,13 +362,13 @@ struct MovieItemView: View { } }.padding(.top, -3) } - if !(item.studios ?? []).isEmpty { + if !(viewModel.item.studios ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(item.studios!, id: \.id) { studio in + ForEach(viewModel.item.studios!, id: \.id) { studio in NavigationLink(destination: LazyView { - LibraryView(withStudio: studio) + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") }) { Text(studio.name ?? "").font(.footnote) } @@ -432,15 +387,10 @@ struct MovieItemView: View { } } } - .onAppear(perform: { - favorite = item.userData?.isFavorite ?? false - watched = item.userData?.played ?? false - settingState = false - }) .onRotate { orientation = $0 } .navigationBarTitleDisplayMode(.inline) - .navigationTitle(item.name ?? "") + .navigationTitle(viewModel.item.name ?? "") } } diff --git a/JellyfinPlayer/SearchBarView.swift b/JellyfinPlayer/SearchBarView.swift index 266a7da0..cb0c3513 100644 --- a/JellyfinPlayer/SearchBarView.swift +++ b/JellyfinPlayer/SearchBarView.swift @@ -19,7 +19,7 @@ struct SearchBar: View { TextField("Search ...", text: $text) .padding(7) - .padding(.horizontal, 25) + .padding(.horizontal, 16) .background(Color(.systemGray6)) .cornerRadius(8) .padding(.horizontal, 10) diff --git a/JellyfinPlayer/SeasonItemView.swift b/JellyfinPlayer/SeasonItemView.swift index fedf22c4..d69c5fcf 100644 --- a/JellyfinPlayer/SeasonItemView.swift +++ b/JellyfinPlayer/SeasonItemView.swift @@ -11,45 +11,17 @@ import JellyfinAPI struct SeasonItemView: View { @StateObject - var tempViewModel = ViewModel() + var viewModel: SeasonItemViewModel @State private var orientation = UIDeviceOrientation.unknown @Environment(\.horizontalSizeClass) var hSizeClass @Environment(\.verticalSizeClass) var vSizeClass - var item: BaseItemDto = BaseItemDto() - @State private var episodes: [BaseItemDto] = [] - - @State private var isLoading: Bool = true - @State private var viewDidLoad: Bool = false - - init(item: BaseItemDto) { - self.item = item - } - - func onAppear() { - if viewDidLoad { - return - } - - DispatchQueue.global(qos: .userInitiated).async { - TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seasonId: item.id ?? "") - .sink(receiveCompletion: { completion in - print(completion) - isLoading = false - }, receiveValue: { response in - viewDidLoad = true - episodes = response.items ?? [] - }) - .store(in: &tempViewModel.cancellables) - } - } - @ViewBuilder var portraitHeaderView: some View { - if isLoading { + if viewModel.isLoading { EmptyView() } else { - ImageView(src: item.getSeriesBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: item.getSeriesBackdropImageBlurHash()) + ImageView(src: viewModel.item.getSeriesBackdropImage(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: viewModel.item.getSeriesBackdropImageBlurHash()) .opacity(0.4) .blur(radius: 2.0) } @@ -57,17 +29,17 @@ struct SeasonItemView: View { var portraitHeaderOverlayView: some View { HStack(alignment: .bottom, spacing: 12) { - ImageView(src: item.getPrimaryImage(maxWidth: 120), bh: item.getPrimaryImageBlurHash()) + ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), bh: viewModel.item.getPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) VStack(alignment: .leading) { - Text(item.name ?? "").font(.headline) + Text(viewModel.item.name ?? "").font(.headline) .fontWeight(.semibold) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) .offset(y: -4) - if item.productionYear != nil { - Text(String(item.productionYear!)).font(.subheadline) + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear!)).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) @@ -85,15 +57,15 @@ struct SeasonItemView: View { overlayAlignment: .bottomLeading, headerHeight: UIScreen.main.bounds.width * 0.5625) { LazyVStack(alignment: .leading) { - if !(item.taglines ?? []).isEmpty { - Text(item.taglines!.first!).font(.body).italic().padding(.top, 7) + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) .padding(.trailing, 16) } - Text(item.overview ?? "").font(.footnote).padding(.top, 3) + Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) .padding(.trailing, 16) - ForEach(episodes, id: \.id) { episode in + ForEach(viewModel.episodes, id: \.id) { episode in NavigationLink(destination: ItemView(item: episode)) { HStack { ImageView(src: episode.getPrimaryImage(maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) @@ -133,13 +105,13 @@ struct SeasonItemView: View { }.offset(x: 12, y: 0) } } - if !(item.studios ?? []).isEmpty { + if !(viewModel.item.studios ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(item.studios!, id: \.id) { studio in + ForEach(viewModel.item.studios!, id: \.id) { studio in NavigationLink(destination: LazyView { - LibraryView(withStudio: studio) + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") }) { Text(studio.name ?? "").font(.footnote) } @@ -155,7 +127,7 @@ struct SeasonItemView: View { } else { GeometryReader { geometry in ZStack { - ImageView(src: item.getSeriesBackdropImage(maxWidth: 200), bh: item.getSeriesBackdropImageBlurHash()) + ImageView(src: viewModel.item.getSeriesBackdropImage(maxWidth: 200), bh: viewModel.item.getSeriesBackdropImageBlurHash()) .opacity(0.4) .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) @@ -164,12 +136,12 @@ struct SeasonItemView: View { HStack { VStack(alignment: .leading) { Spacer().frame(height: 16) - ImageView(src: item.getPrimaryImage(maxWidth: 120), bh: item.getPrimaryImageBlurHash()) + ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 120), bh: viewModel.item.getPrimaryImageBlurHash()) .frame(width: 120, height: 180) .cornerRadius(10) Spacer().frame(height: 4) - if item.productionYear != nil { - Text(String(item.productionYear!)).font(.subheadline) + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear!)).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) } @@ -178,15 +150,15 @@ struct SeasonItemView: View { ScrollView { Spacer().frame(height: 16) LazyVStack(alignment: .leading) { - if !(item.taglines ?? []).isEmpty { - Text(item.taglines!.first!).font(.body).italic().padding(.top, 7) + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines!.first!).font(.body).italic().padding(.top, 7) .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) .padding(.trailing, 16) } - Text(item.overview ?? "").font(.footnote).padding(.top, 3) + Text(viewModel.item.overview ?? "").font(.footnote).padding(.top, 3) .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) .padding(.trailing, 16) - ForEach(episodes, id: \.id) { episode in + ForEach(viewModel.episodes, id: \.id) { episode in NavigationLink(destination: ItemView(item: episode)) { HStack { ImageView(src: episode.getPrimaryImage(maxWidth: 150), bh: episode.getPrimaryImageBlurHash()) @@ -226,13 +198,13 @@ struct SeasonItemView: View { }.offset(x: 12, y: 0) } } - if !(item.studios ?? []).isEmpty { + if !(viewModel.item.studios ?? []).isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - ForEach(item.studios!, id: \.id) { studio in + ForEach(viewModel.item.studios!, id: \.id) { studio in NavigationLink(destination: LazyView { - LibraryView(withStudio: studio) + LibraryView(viewModel: .init(studio: studio), title: studio.name ?? "") }) { Text(studio.name ?? "").font(.footnote) } @@ -250,16 +222,15 @@ struct SeasonItemView: View { } var body: some View { - if isLoading { + if viewModel.isLoading { ProgressView() - .onAppear(perform: onAppear) } else { innerBody .onRotate { orientation = $0 } .navigationBarTitleDisplayMode(.inline) - .navigationTitle("\(item.name ?? "") - \(item.seriesName ?? "")") + .navigationTitle("\(viewModel.item.name ?? "") - \(viewModel.item.seriesName ?? "")") } } } diff --git a/JellyfinPlayer/SeriesItemView.swift b/JellyfinPlayer/SeriesItemView.swift index e0034268..f7276db8 100644 --- a/JellyfinPlayer/SeriesItemView.swift +++ b/JellyfinPlayer/SeriesItemView.swift @@ -11,54 +11,23 @@ import Combine struct SeriesItemView: View { @StateObject - var tempViewModel = ViewModel() + var viewModel: SeriesItemViewModel - var item: BaseItemDto + @State + private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - @State private var seasons: [BaseItemDto] = [] - @State private var isLoading: Bool = true - @State private var viewDidLoad: Bool = false - - func onAppear() { - recalcTracks() - if viewDidLoad { - return - } - - isLoading = true - - DispatchQueue.global(qos: .userInitiated).async { - TvShowsAPI.getSeasons(seriesId: item.id ?? "", fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { response in - isLoading = false - viewDidLoad = true - seasons = response.items ?? [] - }) - .store(in: &tempViewModel.cancellables) - } - } - - // MARK: Grid tracks func recalcTracks() { - let trkCnt: Int = Int(floor(UIScreen.main.bounds.size.width / 125)) - tracks = [] - for _ in (0..: View { +private struct MultiSelectionView: View { let options: [Selectable] let optionToString: (Selectable) -> String let label: String - @Binding var selected: Set + @Binding var selected: Array var body: some View { List { - ForEach(options) { selectable in + ForEach(options, id: \.self) { selectable in Button(action: { toggleSelection(selectable: selectable) }) { HStack { Text(optionToString(selectable)).foregroundColor(Color.primary) Spacer() - if selected.contains { $0.id == selectable.id } { + if selected.contains { $0 == selectable } { Image(systemName: "checkmark").foregroundColor(.accentColor) } } - }.tag(selectable.id) + }.tag(selectable) } }.listStyle(GroupedListStyle()) } private func toggleSelection(selectable: Selectable) { - if let existingIndex = selected.firstIndex(where: { $0.id == selectable.id }) { + if let existingIndex = selected.firstIndex(where: { $0 == selectable }) { selected.remove(at: existingIndex) } else { - selected.insert(selectable) + selected.append(selectable) } } } -struct MultiSelector: View { +struct MultiSelector: View { let label: String let options: [Selectable] let optionToString: (Selectable) -> String - var selected: Binding> + var selected: Binding> private var formattedSelectedListString: String { ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) }) diff --git a/Shared/Typings/Typings.swift b/Shared/Typings/Typings.swift index 87d62223..d4a1ec06 100644 --- a/Shared/Typings/Typings.swift +++ b/Shared/Typings/Typings.swift @@ -5,15 +5,16 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ -import Foundation import Combine +import Foundation import JellyfinAPI struct LibraryFilters: Codable, Hashable { var filters: [ItemFilter] = [] var sortOrder: [APISortOrder] = [.descending] var withGenres: [NameGuidPair] = [] - var sortBy: [String] = ["SortName"] + var tags: [String] = [] + var sortBy: [SortBy] = [.name] } public enum SortBy: String, Codable, CaseIterable { @@ -22,3 +23,52 @@ public enum SortBy: String, Codable, CaseIterable { case name = "SortName" case dateAdded = "DateCreated" } + +extension SortBy { + var localized: String { + switch self { + case .productionYear: + return "Release Year" + case .premiereDate: + return "Premiere date" + case .name: + return "Title" + case .dateAdded: + return "Date added" + } + } +} + +extension ItemFilter { + static var supportedTypes: [ItemFilter] { + [.isUnplayed, isPlayed, .isFavorite, .likes, .isFavoriteOrLikes] + } + + var localized: String { + switch self { + case .isUnplayed: + return "Unplayed" + case .isPlayed: + return "Played" + case .isFavorite: + return "Favorites" + case .likes: + return "Liked" + case .isFavoriteOrLikes: + return "Favorites or Liked" + default: + return "" + } + } +} + +extension APISortOrder { + var localized: String { + switch self { + case .ascending: + return "Ascending" + case .descending: + return "Descending" + } + } +} diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index 88f2af3f..f81cd91f 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -37,7 +37,7 @@ final class ConnectToServerViewModel: ViewModel { if ServerEnvironment.current.server != nil { UserAPI.getPublicUsers() .sink(receiveCompletion: { completion in - self.HandleAPIRequestCompletion(completion: completion) + self.handleAPIRequestCompletion(completion: completion) }, receiveValue: { response in self.publicUsers = response self.isConnectedServer = true @@ -74,7 +74,7 @@ final class ConnectToServerViewModel: ViewModel { func login() { SessionManager.current.login(username: username, password: password) .sink(receiveCompletion: { completion in - self.HandleAPIRequestCompletion(completion: completion) + self.handleAPIRequestCompletion(completion: completion) }, receiveValue: { _ in }) diff --git a/Shared/ViewModels/DetailItemViewModel.swift b/Shared/ViewModels/DetailItemViewModel.swift new file mode 100644 index 00000000..2dba5c83 --- /dev/null +++ b/Shared/ViewModels/DetailItemViewModel.swift @@ -0,0 +1,73 @@ +// + /* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Foundation +import Foundation +import JellyfinAPI + +class DetailItemViewModel: ViewModel { + @Published + var item: BaseItemDto + + @Published + var isWatched = false + @Published + var isFavorited = false + + init(item: BaseItemDto) { + self.item = item + isFavorited = item.userData?.isFavorite ?? false + isWatched = item.userData?.played ?? false + super.init() + } + + func updateWatchState() { + if isWatched { + PlaystateAPI.markUnplayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] _ in + self?.isWatched = false + }) + .store(in: &cancellables) + } else { + PlaystateAPI.markPlayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] _ in + self?.isWatched = true + }) + .store(in: &cancellables) + } + } + + func updateFavoriteState() { + if isFavorited { + UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] _ in + self?.isFavorited = false + }) + .store(in: &cancellables) + } else { + UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] _ in + self?.isFavorited = true + }) + .store(in: &cancellables) + } + } +} diff --git a/Shared/ViewModels/EpisodeItemViewModel.swift b/Shared/ViewModels/EpisodeItemViewModel.swift new file mode 100644 index 00000000..b43b99ef --- /dev/null +++ b/Shared/ViewModels/EpisodeItemViewModel.swift @@ -0,0 +1,15 @@ +// +/* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +final class EpisodeItemViewModel: DetailItemViewModel { +} diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 81b0778c..9f0d6e85 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -24,7 +24,7 @@ final class HomeViewModel: ViewModel { var nextUpItems = [BaseItemDto]() // temp - var recentFilterSet: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: ["DateCreated"]) + var recentFilterSet: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded]) override init() { super.init() @@ -36,7 +36,7 @@ final class HomeViewModel: ViewModel { UserViewsAPI.getUserViews(userId: SessionManager.current.user.user_id!) .trackActivity(loading) .sink(receiveCompletion: { completion in - self.HandleAPIRequestCompletion(completion: completion) + self.handleAPIRequestCompletion(completion: completion) }, receiveValue: { response in response.items!.forEach { item in if item.collectionType == "movies" || item.collectionType == "tvshows" { @@ -47,7 +47,7 @@ final class HomeViewModel: ViewModel { UserAPI.getCurrentUser() .trackActivity(self.loading) .sink(receiveCompletion: { completion in - self.HandleAPIRequestCompletion(completion: completion) + self.handleAPIRequestCompletion(completion: completion) }, receiveValue: { response in self.libraries.forEach { library in if !(response.configuration?.latestItemsExcludes?.contains(library.id!))! { @@ -64,7 +64,7 @@ final class HomeViewModel: ViewModel { mediaTypes: ["Video"], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) .trackActivity(loading) .sink(receiveCompletion: { completion in - self.HandleAPIRequestCompletion(completion: completion) + self.handleAPIRequestCompletion(completion: completion) }, receiveValue: { response in self.resumeItems = response.items ?? [] }) @@ -74,7 +74,7 @@ final class HomeViewModel: ViewModel { fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) .trackActivity(loading) .sink(receiveCompletion: { completion in - self.HandleAPIRequestCompletion(completion: completion) + self.handleAPIRequestCompletion(completion: completion) }, receiveValue: { response in self.nextUpItems = response.items ?? [] }) diff --git a/Shared/ViewModels/LatestMediaViewModel.swift b/Shared/ViewModels/LatestMediaViewModel.swift new file mode 100644 index 00000000..f2e5c4e6 --- /dev/null +++ b/Shared/ViewModels/LatestMediaViewModel.swift @@ -0,0 +1,46 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +final class LatestMediaViewModel: ViewModel { + @Published + var items = [BaseItemDto]() + + var libraryID: String + + init(libraryID: String) { + self.libraryID = libraryID + super.init() + + requestLatestMedia() + } + + func requestLatestMedia() { + UserLibraryAPI.getLatestMedia(userId: SessionManager.current.user.user_id!, parentId: libraryID, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + ], + enableUserData: true, limit: 12) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] response in + self?.items = response + }) + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModels/LibraryFilterViewModel.swift b/Shared/ViewModels/LibraryFilterViewModel.swift new file mode 100644 index 00000000..fb2ae9f0 --- /dev/null +++ b/Shared/ViewModels/LibraryFilterViewModel.swift @@ -0,0 +1,61 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +enum FilterType { + case tag + case genre + case sortOrder + case sortBy + case filter +} + +final class LibraryFilterViewModel: ViewModel { + @Published + var modifyedFilters = LibraryFilters() + + @Published + var possibleGenres = [NameGuidPair]() + @Published + var possibleTags = [String]() + @Published + var possibleSortOrders = APISortOrder.allCases + @Published + var possibleSortBys = SortBy.allCases + @Published + var possibleItemFilters = ItemFilter.supportedTypes + @Published + var enabledFilterType: [FilterType] + + init(filters: LibraryFilters? = nil, + enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter]) { + self.enabledFilterType = enabledFilterType + super.init() + if let filters = filters { + self.modifyedFilters = filters + } + requestQueryFilters() + } + + func requestQueryFilters() { + FilterAPI.getQueryFilters(userId: SessionManager.current.user.user_id!) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] queryFilters in + guard let self = self else { return } + self.possibleGenres = queryFilters.genres ?? [] + self.possibleTags = queryFilters.tags ?? [] + }) + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModels/LibraryListViewModel.swift b/Shared/ViewModels/LibraryListViewModel.swift index 7dba1888..98789166 100644 --- a/Shared/ViewModels/LibraryListViewModel.swift +++ b/Shared/ViewModels/LibraryListViewModel.swift @@ -22,14 +22,14 @@ final class LibraryListViewModel: ViewModel { libraries.append(.init(name: "Favorites", id: "favorites")) libraries.append(.init(name: "Genres", id: "genres")) - refresh() + requsetLibraries() } - func refresh() { + func requsetLibraries() { UserViewsAPI.getUserViews(userId: SessionManager.current.user.user_id!) .trackActivity(loading) .sink(receiveCompletion: { completion in - self.HandleAPIRequestCompletion(completion: completion) + self.handleAPIRequestCompletion(completion: completion) }, receiveValue: { response in self.libraries.append(contentsOf: response.items ?? []) }) diff --git a/Shared/ViewModels/LibrarySearchViewModel.swift b/Shared/ViewModels/LibrarySearchViewModel.swift new file mode 100644 index 00000000..065b0e47 --- /dev/null +++ b/Shared/ViewModels/LibrarySearchViewModel.swift @@ -0,0 +1,45 @@ +// +/* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +final class LibrarySearchViewModel: ViewModel { + @Published + var items = [BaseItemDto]() + + @Published + var searchQuery = "" + var parentID: String? + + init(parentID: String?) { + self.parentID = parentID + super.init() + + $searchQuery + .debounce(for: 0.25, scheduler: DispatchQueue.main) + .sink(receiveValue: search(with:)) + .store(in: &cancellables) + } + + func search(with query: String) { + ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, limit: 60, recursive: true, searchTerm: query, + sortOrder: [.ascending], parentId: parentID, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + includeItemTypes: ["Movie", "Series"], sortBy: ["SortName"], enableUserData: true, enableImages: true) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] response in + self?.items = response.items ?? [] + }) + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift new file mode 100644 index 00000000..3c288b89 --- /dev/null +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -0,0 +1,101 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +final class LibraryViewModel: ViewModel { + var parentID: String? + var person: BaseItemPerson? + var genre: NameGuidPair? + var studio: NameGuidPair? + + @Published + var items = [BaseItemDto]() + + @Published + var totalPages = 0 + @Published + var currentPage = 0 + @Published + var hasNextPage = false + @Published + var hasPreviousPage = false + + // temp + @Published + var filters: LibraryFilters + + var enabledFilterType: [FilterType] { + if genre == nil { + return [.tag, .genre, .sortBy, .sortOrder, .filter] + } else { + return [.tag, .sortBy, .sortOrder, .filter] + } + } + + init(parentID: String? = nil, + person: BaseItemPerson? = nil, + genre: NameGuidPair? = nil, + studio: NameGuidPair? = nil, + filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name])) + { + self.parentID = parentID + self.person = person + self.genre = genre + self.studio = studio + self.filters = filters + super.init() + + $filters + .sink(receiveValue: requestItems(with:)) + .store(in: &cancellables) + } + + func requestItems(with filters: LibraryFilters) { + let personIDs: [String] = [person].compactMap(\.?.id) + let studioIDs: [String] = [studio].compactMap(\.?.id) + let genreIDs: [String] + if filters.withGenres.isEmpty { + genreIDs = [genre].compactMap(\.?.id) + } else { + genreIDs = filters.withGenres.compactMap(\.id) + } + let sortBy = filters.sortBy.map(\.rawValue) + + ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, startIndex: currentPage * 100, limit: 100, recursive: true, + searchTerm: nil, sortOrder: filters.sortOrder, parentId: parentID, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + includeItemTypes: ["Movie", "Series"], filters: filters.filters, sortBy: sortBy, tags: filters.tags, + enableUserData: true, personIds: personIDs, studioIds: studioIDs, genreIds: genreIDs, enableImages: true) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] response in + guard let self = self else { return } + let totalPages = ceil(Double(response.totalRecordCount ?? 0) / 100.0) + self.totalPages = Int(totalPages) + self.hasPreviousPage = self.currentPage > 0 + self.hasNextPage = self.currentPage < self.totalPages - 1 + self.items = response.items ?? [] + }) + .store(in: &cancellables) + } + + func requestNextPage() { + currentPage += 1 + requestItems(with: filters) + } + + func requestPreviousPage() { + currentPage -= 1 + requestItems(with: filters) + } +} diff --git a/Shared/ViewModels/MovieItemViewModel.swift b/Shared/ViewModels/MovieItemViewModel.swift new file mode 100644 index 00000000..d37a9281 --- /dev/null +++ b/Shared/ViewModels/MovieItemViewModel.swift @@ -0,0 +1,15 @@ +// +/* + * 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 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +final class MovieItemViewModel: DetailItemViewModel { +} diff --git a/Shared/ViewModels/SeasonItemViewModel.swift b/Shared/ViewModels/SeasonItemViewModel.swift new file mode 100644 index 00000000..c954a255 --- /dev/null +++ b/Shared/ViewModels/SeasonItemViewModel.swift @@ -0,0 +1,40 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +final class SeasonItemViewModel: ViewModel { + @Published + var item: BaseItemDto + + @Published + var episodes = [BaseItemDto]() + + init(item: BaseItemDto) { + self.item = item + super.init() + + requestEpisodes() + } + + func requestEpisodes() { + TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.current.user.user_id!, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + seasonId: item.id ?? "") + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] response in + self?.episodes = response.items ?? [] + }) + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModels/SeriesItemViewModel.swift b/Shared/ViewModels/SeriesItemViewModel.swift new file mode 100644 index 00000000..aca9c6d3 --- /dev/null +++ b/Shared/ViewModels/SeriesItemViewModel.swift @@ -0,0 +1,38 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +final class SeriesItemViewModel: ViewModel { + @Published + var item: BaseItemDto + + @Published + var seasons = [BaseItemDto]() + + init(item: BaseItemDto) { + self.item = item + super.init() + + requestSeasons() + } + + func requestSeasons() { + TvShowsAPI.getSeasons(seriesId: item.id ?? "", fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] response in + self?.seasons = response.items ?? [] + }) + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModels/ViewModel.swift b/Shared/ViewModels/ViewModel.swift index 5039d991..6417aa6e 100644 --- a/Shared/ViewModels/ViewModel.swift +++ b/Shared/ViewModels/ViewModel.swift @@ -32,7 +32,7 @@ class ViewModel: ObservableObject { loading.loading.assign(to: \.isLoading, on: self).store(in: &cancellables) } - func HandleAPIRequestCompletion(completion: Subscribers.Completion) { + func handleAPIRequestCompletion(completion: Subscribers.Completion) { switch completion { case .finished: break