diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index e861ed79..147c0392 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -123,6 +123,8 @@ 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 */; }; 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 */; }; @@ -274,6 +276,7 @@ 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 = ""; }; 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 = ""; }; @@ -338,6 +341,7 @@ 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */, 62E632DB267D2E130063E547 /* LibrarySearchViewModel.swift */, 62E632DF267D30CA0063E547 /* LibraryViewModel.swift */, + 62E632E2267D3BA60063E547 /* MovieItemViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -722,6 +726,7 @@ 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 */, @@ -776,6 +781,7 @@ 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */, + 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, 535870AD2669D8DD00D05A09 /* Typings.swift in Sources */, 62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */, 6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */, diff --git a/JellyfinPlayer/ItemView.swift b/JellyfinPlayer/ItemView.swift index 4db22550..9ebe3db7 100644 --- a/JellyfinPlayer/ItemView.swift +++ b/JellyfinPlayer/ItemView.swift @@ -39,7 +39,7 @@ struct ItemView: View { } VStack { if item.type == "Movie" { - MovieItemView(item: item) + MovieItemView(viewModel: .init(item: item)) } else if item.type == "Season" { SeasonItemView(item: item) } else if item.type == "Series" { diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift index 8c6e3623..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,19 +125,19 @@ 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(viewModel: .init(genre: genre), title: genre.name ?? "") }) { @@ -195,13 +147,13 @@ 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(viewModel: .init(person: person), title: person.name ?? "") @@ -228,11 +180,11 @@ 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(viewModel: .init(studio: studio), title: studio.name ?? "") @@ -249,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) @@ -258,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)) } @@ -283,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) @@ -309,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) @@ -326,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 { @@ -347,21 +300,22 @@ 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(viewModel: .init(genre: genre), title: genre.name ?? "") }) { @@ -373,13 +327,13 @@ 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(viewModel: .init(person: person), title: person.name ?? "") @@ -408,11 +362,11 @@ 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(viewModel: .init(studio: studio), title: studio.name ?? "") }) { @@ -433,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/Shared/ViewModels/MovieItemViewModel.swift b/Shared/ViewModels/MovieItemViewModel.swift new file mode 100644 index 00000000..c3c5b4c3 --- /dev/null +++ b/Shared/ViewModels/MovieItemViewModel.swift @@ -0,0 +1,75 @@ +// +/* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import Combine +import Foundation +import JellyfinAPI + +final class MovieItemViewModel: 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() { + guard let id = item.id else { return } + if isWatched { + PlaystateAPI.markUnplayedItem(userId: SessionManager.current.user.user_id!, itemId: 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: 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() { + guard let id = item.id else { return } + if isFavorited { + UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: 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: id) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.HandleAPIRequestCompletion(completion: completion) + }, receiveValue: { [weak self] _ in + self?.isFavorited = true + }) + .store(in: &cancellables) + } + } +}