diff --git a/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift b/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift index 6d3afa1a..b302e613 100644 --- a/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift +++ b/JellyfinPlayer tvOS/Views/ItemView/ItemDetailsView.swift @@ -12,55 +12,8 @@ import SwiftUI struct ItemDetailsView: View { @ObservedObject var viewModel: ItemViewModel - private let detailItems: [(String, String)] - private let mediaItems: [(String, String)] @FocusState private var focused: Bool - init(viewModel: ItemViewModel) { - self.viewModel = viewModel - - var initialDetailItems: [(String, String)] = [] - - if let productionYear = viewModel.item.productionYear { - initialDetailItems.append(("Released", "\(productionYear)")) - } - - if let rating = viewModel.item.officialRating { - initialDetailItems.append(("Rated", "\(rating)")) - } - - if let runtime = viewModel.item.getItemRuntime() { - initialDetailItems.append(("Runtime", "\(runtime)")) - } - - var initialMediatems: [(String, String)] = [] - - if let container = viewModel.item.container { - let containerList = container.split(separator: ",") - if containerList.count > 1 { - initialMediatems.append(("Containers", containerList.joined(separator: ", "))) - } else { - initialMediatems.append(("Container", containerList.joined(separator: ", "))) - } - } - - if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel { - - if !itemVideoPlayerViewModel.audioStreams.isEmpty { - let audioList = itemVideoPlayerViewModel.audioStreams.compactMap({ $0.displayTitle }).joined(separator: ", ") - initialMediatems.append(("Audio", audioList)) - } - - if !itemVideoPlayerViewModel.subtitleStreams.isEmpty { - let subtitlesList = itemVideoPlayerViewModel.subtitleStreams.compactMap({ $0.displayTitle }).joined(separator: ", ") - initialMediatems.append(("Subtitles", subtitlesList)) - } - } - - detailItems = initialDetailItems - mediaItems = initialMediatems - } - var body: some View { ZStack(alignment: .leading) { diff --git a/JellyfinPlayer tvOS/Views/LatestMediaView.swift b/JellyfinPlayer tvOS/Views/LatestMediaView.swift index 0bc78790..79905357 100644 --- a/JellyfinPlayer tvOS/Views/LatestMediaView.swift +++ b/JellyfinPlayer tvOS/Views/LatestMediaView.swift @@ -13,8 +13,9 @@ struct LatestMediaView: View { @StateObject var tempViewModel = ViewModel() @State var items: [BaseItemDto] = [] - private var library_id: String = "" @State private var viewDidLoad: Bool = false + + private var library_id: String = "" init(usingParentID: String) { library_id = usingParentID @@ -26,15 +27,13 @@ struct LatestMediaView: View { } viewDidLoad = true - DispatchQueue.global(qos: .userInitiated).async { - UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { response in - items = response - }) - .store(in: &tempViewModel.cancellables) - } + UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12) + .sink(receiveCompletion: { completion in + print(completion) + }, receiveValue: { response in + items = response + }) + .store(in: &tempViewModel.cancellables) } var body: some View { diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index f1d84a70..9fd95481 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -76,7 +76,6 @@ 5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */; }; 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276D263C25100035E14B /* ContinueWatchingView.swift */; }; 53892770263C25230035E14B /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276F263C25230035E14B /* NextUpView.swift */; }; - 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53892771263C8C6F0035E14B /* LoadingView.swift */; }; 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; }; 53913BEF26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; 53913BF026D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; }; @@ -230,6 +229,13 @@ C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; }; C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; }; E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; }; + E10D87DA2784E4F100BD264C /* ItemViewDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */; }; + E10D87DC2784EC5200BD264C /* EpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */; }; + E10D87DE278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; + E10D87DF278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; + E10D87E0278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; + E10D87E227852FD000BD264C /* EpisodesRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */; }; + E10D87E327852FD000BD264C /* EpisodesRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */; }; E10EAA45277BB646000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA44277BB646000269ED /* JellyfinAPI */; }; E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA46277BB670000269ED /* JellyfinAPI */; }; E10EAA4D277BB716000269ED /* Sliders in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA4C277BB716000269ED /* Sliders */; }; @@ -493,7 +499,6 @@ 5377CC02263B596B003A4E83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5389276D263C25100035E14B /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; 5389276F263C25230035E14B /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; - 53892771263C8C6F0035E14B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; 53913BCA26D323FE00EB3286 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = ""; }; 53913BCD26D323FE00EB3286 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Localizable.strings; sourceTree = ""; }; @@ -587,6 +592,10 @@ C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; + E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewDetailsView.swift; sourceTree = ""; }; + E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = ""; }; + E10D87DD278510E300BD264C /* PosterSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterSize.swift; sourceTree = ""; }; + E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowViewModel.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = ""; }; E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; @@ -767,6 +776,7 @@ E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, 62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */, + E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, 62E632F2267D54030063E547 /* ItemViewModel.swift */, 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */, @@ -891,6 +901,7 @@ E1AA331E2782639D00F6439C /* OverlayType.swift */, E193D4DA27193CCA00900D82 /* PillStackable.swift */, E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */, + E10D87DD278510E300BD264C /* PosterSize.swift */, E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */, 535870AC2669D8DD00D05A09 /* Typings.swift */, E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */, @@ -1299,7 +1310,6 @@ 6213388F265F83A900A81A2A /* LibraryListView.swift */, 53EE24E5265060780068F029 /* LibrarySearchView.swift */, 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, - 53892771263C8C6F0035E14B /* LoadingView.swift */, 5389276F263C25230035E14B /* NextUpView.swift */, E1E5D54A2783E26100692DFE /* SettingsView */, E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, @@ -1315,7 +1325,9 @@ isa = PBXGroup; children = ( 535BAE9E2649E569005FA86D /* ItemView.swift */, + E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */, E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */, + E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */, E18845FB26DEACC400B0C5B7 /* Landscape */, E18845FA26DEACBE00B0C5B7 /* Portrait */, ); @@ -1955,6 +1967,7 @@ 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */, E193D53727193F8700900D82 /* LibraryListCoordinator.swift in Sources */, + E10D87DF278510E400BD264C /* PosterSize.swift in Sources */, E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */, E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */, E193D54D2719426600900D82 /* LibraryFilterView.swift in Sources */, @@ -2020,6 +2033,7 @@ E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */, E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */, 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, + E10D87E327852FD000BD264C /* EpisodesRowViewModel.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, E193D547271941C500900D82 /* UserListView.swift in Sources */, @@ -2066,6 +2080,7 @@ E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, + E10D87E227852FD000BD264C /* EpisodesRowViewModel.swift in Sources */, C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, @@ -2100,6 +2115,7 @@ C4BE0763271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */, 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */, + E10D87DA2784E4F100BD264C /* ItemViewDetailsView.swift in Sources */, E1C812C3277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */, E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */, 091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */, @@ -2125,6 +2141,7 @@ E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, 621338B32660A07800A81A2A /* LazyView.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, + E10D87DC2784EC5200BD264C /* EpisodesRowView.swift in Sources */, E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, @@ -2172,10 +2189,10 @@ E13DD3EF27178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, C40CD928271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */, + E10D87DE278510E400BD264C /* PosterSize.swift in Sources */, E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, - 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, 625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2195,6 +2212,7 @@ E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */, 628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */, 6264E88E273850380081A12A /* Strings.swift in Sources */, + E10D87E0278510E400BD264C /* PosterSize.swift in Sources */, E1AD105426D97161003E4A08 /* BaseItemDtoExtensions.swift in Sources */, E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */, 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */, diff --git a/JellyfinPlayer/Components/EpisodeCardVStackView.swift b/JellyfinPlayer/Components/EpisodeCardVStackView.swift index fafad2ab..4df40dbd 100644 --- a/JellyfinPlayer/Components/EpisodeCardVStackView.swift +++ b/JellyfinPlayer/Components/EpisodeCardVStackView.swift @@ -60,7 +60,6 @@ struct EpisodeCardVStackView: View { .overlay( Rectangle() .fill(Color.jellyfinPurple) - .mask(ProgressBar()) .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 1.5), height: 7) .padding(0), alignment: .bottomLeading ) diff --git a/JellyfinPlayer/Components/PillHStackView.swift b/JellyfinPlayer/Components/PillHStackView.swift index d66cf279..f89bd307 100644 --- a/JellyfinPlayer/Components/PillHStackView.swift +++ b/JellyfinPlayer/Components/PillHStackView.swift @@ -13,7 +13,6 @@ struct PillHStackView: View { let title: String let items: [ItemType] -// let navigationView: (ItemType) -> NavigationView let selectedAction: (ItemType) -> Void var body: some View { diff --git a/JellyfinPlayer/Components/PortraitHStackView.swift b/JellyfinPlayer/Components/PortraitHStackView.swift index ec2e2ce9..5b09eac0 100644 --- a/JellyfinPlayer/Components/PortraitHStackView.swift +++ b/JellyfinPlayer/Components/PortraitHStackView.swift @@ -12,66 +12,70 @@ import SwiftUI struct PortraitImageHStackView: View { let items: [ItemType] - let maxWidth: Int + let maxWidth: CGFloat let horizontalAlignment: HorizontalAlignment + let textAlignment: TextAlignment let topBarView: () -> TopBarView let selectedAction: (ItemType) -> Void - init(items: [ItemType], maxWidth: Int, horizontalAlignment: HorizontalAlignment = .leading, topBarView: @escaping () -> TopBarView, selectedAction: @escaping (ItemType) -> Void) { + init(items: [ItemType], + maxWidth: CGFloat = 110, + horizontalAlignment: HorizontalAlignment = .leading, + textAlignment: TextAlignment = .leading, + topBarView: @escaping () -> TopBarView, + selectedAction: @escaping (ItemType) -> Void) { self.items = items self.maxWidth = maxWidth self.horizontalAlignment = horizontalAlignment + self.textAlignment = textAlignment self.topBarView = topBarView self.selectedAction = selectedAction } var body: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 0) { topBarView() ScrollView(.horizontal, showsIndicators: false) { - VStack { - Spacer().frame(height: 8) - HStack(alignment: .top) { - - Spacer().frame(width: 16) - - ForEach(items, id: \.title) { item in - Button { - selectedAction(item) - } label: { - VStack { - ImageView(src: item.imageURLContsructor(maxWidth: maxWidth), - bh: item.blurHash, - failureInitials: item.failureInitials) - .frame(width: 100, height: CGFloat(maxWidth)) - .cornerRadius(10) - .shadow(radius: 4, y: 2) + HStack(alignment: .top, spacing: 15) { + ForEach(items, id: \.self.portraitImageID) { item in + Button { + selectedAction(item) + } label: { + VStack(alignment: horizontalAlignment) { + ImageView(src: item.imageURLContsructor(maxWidth: Int(maxWidth)), + bh: item.blurHash, + failureInitials: item.failureInitials) + .frame(width: maxWidth, height: maxWidth * 1.5) + .cornerRadius(10) + .shadow(radius: 4, y: 2) + if item.showTitle { Text(item.title) .font(.footnote) .fontWeight(.regular) - .frame(width: 100) .foregroundColor(.primary) - .multilineTextAlignment(.center) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) .lineLimit(2) + } - if let description = item.description { - Text(description) - .font(.caption) - .fontWeight(.medium) - .frame(width: 100) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .lineLimit(2) - } + if let description = item.subtitle { + Text(description) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) } } + .frame(width: maxWidth) } - Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } } - }.padding(.top, -3) + .padding(.horizontal) + } } } } diff --git a/JellyfinPlayer/Components/PortraitItemView.swift b/JellyfinPlayer/Components/PortraitItemView.swift index cf638bad..79ef3b79 100644 --- a/JellyfinPlayer/Components/PortraitItemView.swift +++ b/JellyfinPlayer/Components/PortraitItemView.swift @@ -23,7 +23,6 @@ struct PortraitItemView: View { .shadow(radius: 4, y: 2) .overlay(Rectangle() .fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) - .mask(ProgressBar()) .frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7) .padding(0), alignment: .bottomLeading) .overlay(ZStack { diff --git a/JellyfinPlayer/Views/ContinueWatchingView.swift b/JellyfinPlayer/Views/ContinueWatchingView.swift index 2b2702c4..25e8e3e8 100644 --- a/JellyfinPlayer/Views/ContinueWatchingView.swift +++ b/JellyfinPlayer/Views/ContinueWatchingView.swift @@ -9,71 +9,79 @@ import JellyfinAPI import SwiftUI -struct ProgressBar: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - - let tl = CGPoint(x: rect.minX, y: rect.minY) - let tr = CGPoint(x: rect.maxX, y: rect.minY) - let br = CGPoint(x: rect.maxX, y: rect.maxY) - let bls = CGPoint(x: rect.minX + 10, y: rect.maxY) - let blc = CGPoint(x: rect.minX + 10, y: rect.maxY - 10) - - path.move(to: tl) - path.addLine(to: tr) - path.addLine(to: br) - path.addLine(to: bls) - path.addRelativeArc(center: blc, radius: 10, - startAngle: Angle.degrees(90), delta: Angle.degrees(90)) - - return path - } -} - struct ContinueWatchingView: View { + @EnvironmentObject var homeRouter: HomeCoordinator.Router - var items: [BaseItemDto] + @ObservedObject var viewModel: HomeViewModel var body: some View { ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(items, id: \.id) { item in + HStack(alignment: .top, spacing: 20) { + ForEach(viewModel.resumeItems, id: \.id) { item in + Button { homeRouter.route(to: \.item, item) } label: { VStack(alignment: .leading) { - ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash()) - .frame(width: 320, height: 180) - .cornerRadius(10) - .shadow(radius: 4, y: 2) - .shadow(radius: 4, y: 2) - .overlay(Rectangle() - .fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) - .mask(ProgressBar()) - .frame(width: CGFloat((item.userData?.playedPercentage ?? 0) * 3.2), height: 7) - .padding(0), alignment: .bottomLeading) - HStack { + + ZStack { + ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash()) + .frame(width: 320, height: 180) + + HStack { + VStack{ + + Spacer() + + ZStack(alignment: .bottom) { + + LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], + startPoint: .top, + endPoint: .bottom) + .frame(height: 35) + + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? "Continue") + .font(.subheadline) + .padding(.bottom, 5) + .padding(.leading, 10) + .foregroundColor(.white) + + HStack { + Color.jellyfinPurple + .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) + + Spacer(minLength: 0) + } + } + } + } + } + } + .frame(width: 320, height: 180) + .mask(Rectangle().cornerRadius(10)) + .shadow(radius: 4, y: 2) + + VStack(alignment: .leading) { Text("\(item.seriesName ?? item.name ?? "")") .font(.callout) .fontWeight(.semibold) .foregroundColor(.primary) .lineLimit(1) - if item.type == "Episode" { - Text("• \(item.getEpisodeLocator() ?? "") - \(item.name ?? "")") + + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") .font(.callout) - .fontWeight(.semibold) + .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - .offset(x: -1.4) } - Spacer() - }.frame(width: 320, alignment: .leading) - }.padding(.top, 10) - .padding(.bottom, 5) + } + } } - }.padding(.trailing, 16) - }.frame(height: 215) - .padding(EdgeInsets(top: 8, leading: 20, bottom: 10, trailing: 2)) + } + } + .padding(.horizontal) } } } diff --git a/JellyfinPlayer/Views/HomeView.swift b/JellyfinPlayer/Views/HomeView.swift index 892d0ab7..71e9f944 100644 --- a/JellyfinPlayer/Views/HomeView.swift +++ b/JellyfinPlayer/Views/HomeView.swift @@ -51,35 +51,49 @@ struct HomeView: View { ScrollView { VStack(alignment: .leading) { if !viewModel.resumeItems.isEmpty { - ContinueWatchingView(items: viewModel.resumeItems) + ContinueWatchingView(viewModel: viewModel) } if !viewModel.nextUpItems.isEmpty { - NextUpView(items: viewModel.nextUpItems) - } - - ForEach(viewModel.libraries, id: \.self) { library in - HStack { - Text(L10n.latestWithString(library.name ?? "")) + PortraitImageHStackView(items: viewModel.nextUpItems, + horizontalAlignment: .leading) { + L10n.nextUp.text .font(.title2) .fontWeight(.bold) - Spacer() - Button { - homeRouter - .route(to: \.library, (viewModel: .init(parentID: library.id!, - filters: viewModel.recentFilterSet), - title: library.name ?? "")) - } label: { - HStack { - L10n.seeAll.text.font(.subheadline).fontWeight(.bold) - Image(systemName: "chevron.right").font(Font.subheadline.bold()) + .padding() + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } + + } + + ForEach(viewModel.libraries, id: \.self) { library in + + LatestMediaView(viewModel: LatestMediaViewModel(libraryID: library.id!)) { + HStack { + Text(L10n.latestWithString(library.name ?? "")) + .font(.title2) + .fontWeight(.bold) + + Spacer() + + Button { + homeRouter + .route(to: \.library, (viewModel: .init(parentID: library.id!, + filters: viewModel.recentFilterSet), + title: library.name ?? "")) + } label: { + HStack { + L10n.seeAll.text.font(.subheadline).fontWeight(.bold) + Image(systemName: "chevron.right").font(Font.subheadline.bold()) + } } } - }.padding(.leading, 16) - .padding(.trailing, 16) - LatestMediaView(viewModel: .init(libraryID: library.id!)) + .padding() + } + } } - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .phone ? 20 : 30) + .padding(.bottom, 50) } .introspectScrollView { scrollView in let control = UIRefreshControl() diff --git a/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift b/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift new file mode 100644 index 00000000..a75504c8 --- /dev/null +++ b/JellyfinPlayer/Views/ItemView/EpisodesRowView.swift @@ -0,0 +1,150 @@ +// + /* + * 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 SwiftUI +import JellyfinAPI + +struct EpisodesRowView: View { + + @EnvironmentObject var itemRouter: ItemCoordinator.Router + @ObservedObject var viewModel: EpisodesRowViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + HStack { + Menu { + ForEach(Array(viewModel.seasonsEpisodes.keys).sorted(by: { $0.name ?? "" < $1.name ?? ""}), id:\.self) { season in + Button { + viewModel.selectedSeason = season + } label: { + if season.id == viewModel.selectedSeason?.id { + Label(season.name ?? "Season", systemImage: "checkmark") + } else { + Text(season.name ?? "Season") + } + } + } + } label: { + HStack(spacing: 5) { + Text(viewModel.selectedSeason?.name ?? "Unknown") + .fontWeight(.semibold) + .fixedSize() + Image(systemName: "chevron.down") + } + } + + Spacer() + } + .padding() + + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { reader in + HStack(alignment: .top, spacing: 15) { + if viewModel.isLoading { + VStack(alignment: .leading) { + + ZStack { + Color.gray.ignoresSafeArea() + + ProgressView() + } + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + + VStack(alignment: .leading) { + Text("--") + .font(.footnote) + .foregroundColor(.secondary) + Text("Loading") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + } + + Spacer() + } + .frame(width: 200) + } else if let selectedSeason = viewModel.selectedSeason { + if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty { + VStack(alignment: .leading) { + + Color.gray.ignoresSafeArea() + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + + VStack(alignment: .leading) { + Text("--") + .font(.footnote) + .foregroundColor(.secondary) + Text("No episodes available") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + } + + Spacer() + } + .frame(width: 200) + } else { + ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id:\.self) { episode in + Button { + itemRouter.route(to: \.item, episode) + } label: { + HStack(alignment: .top) { + VStack(alignment: .leading) { + + ImageView(src: episode.getBackdropImage(maxWidth: 200), + bh: episode.getBackdropImageBlurHash()) + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "") + .font(.footnote) + .foregroundColor(.secondary) + Text(episode.name ?? "") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + Text(episode.overview ?? "") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + .lineLimit(3) + } + + Spacer() + } + .frame(width: 200) + } + } + .buttonStyle(PlainButtonStyle()) + .id(episode.name) + } + } + } + } + .padding(.horizontal) + .onChange(of: viewModel.selectedSeason) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } + } + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } +} diff --git a/JellyfinPlayer/Views/ItemView/ItemView.swift b/JellyfinPlayer/Views/ItemView/ItemView.swift index e95dbc72..0e257ba7 100644 --- a/JellyfinPlayer/Views/ItemView/ItemView.swift +++ b/JellyfinPlayer/Views/ItemView/ItemView.swift @@ -19,7 +19,11 @@ struct ItemNavigationView: View { var body: some View { ItemView(item: item) - .navigationBarTitle("", displayMode: .inline) + .navigationBarTitle(item.name ?? "", displayMode: .inline) + .introspectNavigationController { navigationController in + let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear] + navigationController.navigationBar.titleTextAttributes = textAttributes + } } } @@ -60,21 +64,6 @@ private struct ItemView: View { } label: { Image(systemName: "ellipsis.circle.fill") } - case .episode: - Menu { - Button { - (viewModel as? EpisodeItemViewModel)?.routeToSeriesItem() - } label: { - Label("Show Series", systemImage: "text.below.photo") - } - Button { - (viewModel as? EpisodeItemViewModel)?.routeToSeasonItem() - } label: { - Label("Show Season", systemImage: "square.fill.text.grid.1x2") - } - } label: { - Image(systemName: "ellipsis.circle.fill") - } default: EmptyView() } diff --git a/JellyfinPlayer/Views/ItemView/ItemViewBody.swift b/JellyfinPlayer/Views/ItemView/ItemViewBody.swift index 3df1d559..823cb270 100644 --- a/JellyfinPlayer/Views/ItemView/ItemViewBody.swift +++ b/JellyfinPlayer/Views/ItemView/ItemViewBody.swift @@ -7,12 +7,15 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Defaults import JellyfinAPI import SwiftUI struct ItemViewBody: View { + @EnvironmentObject var itemRouter: ItemCoordinator.Router @EnvironmentObject private var viewModel: ItemViewModel + @Default(.showCastAndCrew) var showCastAndCrew var body: some View { VStack(alignment: .leading) { @@ -27,13 +30,11 @@ struct ItemViewBody: View { if let seriesViewModel = viewModel as? SeriesItemViewModel { PortraitImageHStackView(items: seriesViewModel.seasons, - maxWidth: 150, topBarView: { L10n.seasons.text - .font(.callout) .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) + .padding(.bottom) + .padding(.horizontal) }, selectedAction: { season in itemRouter.route(to: \.item, season) }) @@ -46,6 +47,7 @@ struct ItemViewBody: View { selectedAction: { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) }) + .padding(.bottom) // MARK: Studios @@ -53,42 +55,66 @@ struct ItemViewBody: View { PillHStackView(title: L10n.studios, items: studios) { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + .padding(.bottom) + } + + // MARK: Episodes + + if let episodeViewModel = viewModel as? EpisodeItemViewModel { + EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: episodeViewModel)) + } + + if let episodeViewModel = viewModel as? EpisodeItemViewModel { + if let seriesItem = episodeViewModel.series { + let a = [seriesItem] + PortraitImageHStackView(items: a) { + Text("Series") + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + } selectedAction: { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } } } // MARK: Cast & Crew - if let castAndCrew = viewModel.item.people { - PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, - maxWidth: 150, - topBarView: { - Text("Cast & Crew") - .font(.callout) - .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) - }, - selectedAction: { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - }) + if showCastAndCrew { + if let castAndCrew = viewModel.item.people { + PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, + topBarView: { + Text("Cast & Crew") + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + }, + selectedAction: { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + }) + } } // MARK: More Like This if !viewModel.similarItems.isEmpty { PortraitImageHStackView(items: viewModel.similarItems, - maxWidth: 150, topBarView: { L10n.moreLikeThis.text - .font(.callout) .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) + .padding(.bottom) + .padding(.horizontal) }, selectedAction: { item in itemRouter.route(to: \.item, item) }) } + + // MARK: Details + + ItemViewDetailsView(viewModel: viewModel) + .padding() } } } diff --git a/JellyfinPlayer/Views/ItemView/ItemViewDetailsView.swift b/JellyfinPlayer/Views/ItemView/ItemViewDetailsView.swift new file mode 100644 index 00000000..63e76eef --- /dev/null +++ b/JellyfinPlayer/Views/ItemView/ItemViewDetailsView.swift @@ -0,0 +1,58 @@ +// + /* + * 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 JellyfinAPI +import SwiftUI + +struct ItemViewDetailsView: View { + + @ObservedObject var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading) { + + if !viewModel.informationItems.isEmpty { + VStack(alignment: .leading, spacing: 20) { + Text("Information") + .font(.title3) + .fontWeight(.bold) + + ForEach(viewModel.informationItems, id: \.self.title) { informationItem in + VStack(alignment: .leading, spacing: 2) { + Text(informationItem.title) + .font(.subheadline) + Text(informationItem.content) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + } + } + .padding(.bottom, 20) + } + + if !viewModel.mediaItems.isEmpty { + VStack(alignment: .leading, spacing: 20) { + Text("Media") + .font(.title3) + .fontWeight(.bold) + + ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in + VStack(alignment: .leading, spacing: 2) { + Text(mediaItem.title) + .font(.subheadline) + Text(mediaItem.content) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + } + } + } + } + } +} diff --git a/JellyfinPlayer/Views/LatestMediaView.swift b/JellyfinPlayer/Views/LatestMediaView.swift index 300f435e..3672fef2 100644 --- a/JellyfinPlayer/Views/LatestMediaView.swift +++ b/JellyfinPlayer/Views/LatestMediaView.swift @@ -8,21 +8,18 @@ import Stinsen import SwiftUI -struct LatestMediaView: View { +struct LatestMediaView: View { + @EnvironmentObject var homeRouter: HomeCoordinator.Router @StateObject var viewModel: LatestMediaViewModel + var topBarView: () -> TopBarView var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(viewModel.items, id: \.id) { item in - Button { - homeRouter.route(to: \.item, item) - } label: { - PortraitItemView(item: item) - } - }.padding(.trailing, 16) - }.padding(.leading, 20) - }.frame(height: 200) + PortraitImageHStackView(items: viewModel.items, + horizontalAlignment: .leading) { + topBarView() + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } } } diff --git a/JellyfinPlayer/Views/LibraryListView.swift b/JellyfinPlayer/Views/LibraryListView.swift index 83bbd080..e6bf0364 100644 --- a/JellyfinPlayer/Views/LibraryListView.swift +++ b/JellyfinPlayer/Views/LibraryListView.swift @@ -38,27 +38,6 @@ struct LibraryListView: View { .shadow(radius: 5) .padding(.bottom, 5) - NavigationLink(destination: LazyView { - L10n.wip.text - }) { - ZStack { - HStack { - Spacer() - L10n.allGenres.text - .foregroundColor(.black) - .font(.subheadline) - .fontWeight(.semibold) - Spacer() - } - } - .padding(16) - .background(Color.white) - .frame(minWidth: 100, maxWidth: .infinity) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 15) - if !viewModel.isLoading { ForEach(viewModel.libraries, id: \.id) { library in if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" { diff --git a/JellyfinPlayer/Views/LoadingView.swift b/JellyfinPlayer/Views/LoadingView.swift deleted file mode 100644 index be7b67a2..00000000 --- a/JellyfinPlayer/Views/LoadingView.swift +++ /dev/null @@ -1,85 +0,0 @@ -/* JellyfinPlayer/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 SwiftUI - -struct LoadingView: View where Content: View { - @Environment(\.colorScheme) var colorScheme - @Binding var isShowing: Bool // should the modal be visible? - var content: () -> Content - var text: String? // the text to display under the ProgressView - defaults to "Loading..." - - var body: some View { - GeometryReader { _ in - ZStack(alignment: .center) { - // the content to display - if the modal is showing, we'll blur it - content() - .disabled(isShowing) - .blur(radius: isShowing ? 2 : 0) - - // all contents inside here will only be shown when isShowing is true - if isShowing { - // this Rectangle is a semi-transparent black overlay - Rectangle() - .fill(Color.black).opacity(isShowing ? 0.6 : 0) - .edgesIgnoringSafeArea(.all) - - // the magic bit - our ProgressView just displays an activity - // indicator, with some text underneath showing what we are doing - HStack { - ProgressView() - Text(text ?? L10n.loading).fontWeight(.semibold).font(.callout).offset(x: 60) - Spacer() - } - .padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 10)) - .frame(width: 250) - .background(colorScheme == .dark ? Color(UIColor.systemGray6) : Color.white) - .foregroundColor(Color.primary) - .cornerRadius(16) - } - } - } - } -} - -struct LoadingViewNoBlur: View where Content: View { - @Environment(\.colorScheme) var colorScheme - @Binding var isShowing: Bool // should the modal be visible? - var content: () -> Content - var text: String? // the text to display under the ProgressView - defaults to "Loading..." - - var body: some View { - GeometryReader { _ in - ZStack(alignment: .center) { - // the content to display - if the modal is showing, we'll blur it - content() - .disabled(isShowing) - - // all contents inside here will only be shown when isShowing is true - if isShowing { - // this Rectangle is a semi-transparent black overlay - Rectangle() - .fill(Color.black).opacity(isShowing ? 0.6 : 0) - .edgesIgnoringSafeArea(.all) - - // the magic bit - our ProgressView just displays an activity - // indicator, with some text underneath showing what we are doing - HStack { - ProgressView() - Text(text ?? L10n.loading).fontWeight(.semibold).font(.callout).offset(x: 60) - Spacer() - } - .padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 10)) - .frame(width: 250) - .background(colorScheme == .dark ? Color(UIColor.systemGray6) : Color.white) - .foregroundColor(Color.primary) - .cornerRadius(16) - } - } - } - } -} diff --git a/JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift b/JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift index eb0e611a..0154107b 100644 --- a/JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView/OverlaySettingsView.swift @@ -30,7 +30,7 @@ struct OverlaySettingsView: View { Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem) Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem) Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay) - Toggle("Allow Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu) + Toggle("Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu) } } } diff --git a/JellyfinPlayer/Views/SettingsView/SettingsView.swift b/JellyfinPlayer/Views/SettingsView/SettingsView.swift index 0cbe663d..ab98e490 100644 --- a/JellyfinPlayer/Views/SettingsView/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView/SettingsView.swift @@ -25,6 +25,8 @@ struct SettingsView: View { @Default(.videoPlayerJumpForward) var jumpForwardLength @Default(.videoPlayerJumpBackward) var jumpBackwardLength @Default(.jumpGesturesEnabled) var jumpGesturesEnabled + @Default(.showPosterLabels) var showPosterLabels + @Default(.showCastAndCrew) var showCastAndCrew var body: some View { Form { @@ -114,26 +116,9 @@ struct SettingsView: View { } Section(header: L10n.accessibility.text) { -// Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles) -// SearchablePicker(label: "Preferred subtitle language", -// options: viewModel.langs, -// optionToString: { $0.name }, -// selected: Binding(get: { -// viewModel.langs -// .first(where: { $0.isoCode == autoSelectSubtitlesLangcode -// }) ?? -// .auto -// }, -// set: { autoSelectSubtitlesLangcode = $0.isoCode })) -// SearchablePicker(label: "Preferred audio language", -// options: viewModel.langs, -// optionToString: { $0.name }, -// selected: Binding(get: { -// viewModel.langs -// .first(where: { $0.isoCode == autoSelectAudioLangcode }) ?? -// .auto -// }, -// set: { autoSelectAudioLangcode = $0.isoCode })) + Toggle("Show Poster Labels", isOn: $showPosterLabels) + Toggle("Show Cast and Crew", isOn: $showCastAndCrew) + Picker(L10n.appearance, selection: $appAppearance) { ForEach(AppAppearance.allCases, id: \.self) { appearance in Text(appearance.localizedName).tag(appearance.rawValue) diff --git a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift index 5a6a1d74..880a9244 100644 --- a/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/JellyfinPlayer/Views/VideoPlayer/VLCPlayerViewController.swift @@ -84,10 +84,9 @@ class VLCPlayerViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) + NotificationCenter.default.removeObserver(self) + + SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didEndPlayback, object: nil) } // MARK: viewDidLoad diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift index b4b23403..1118f3c4 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift @@ -7,24 +7,36 @@ * Copyright 2021 Aiden Vigue & Jellyfin Contributors */ +import Defaults import Foundation import JellyfinAPI // MARK: PortraitImageStackable extension BaseItemDto: PortraitImageStackable { + public var portraitImageID: String { + return id ?? "no id" + } + public func imageURLContsructor(maxWidth: Int) -> URL { - return self.getPrimaryImage(maxWidth: maxWidth) + switch self.itemType { + case .episode: + return getSeriesPrimaryImage(maxWidth: maxWidth) + default: + return self.getPrimaryImage(maxWidth: maxWidth) + } } public var title: String { - return self.name ?? "" + switch self.itemType { + case .episode: + return self.seriesName ?? self.name ?? "" + default: + return self.name ?? "" + } } - public var description: String? { + public var subtitle: String? { switch self.itemType { - case .season: - guard let productionYear = productionYear else { return nil } - return "\(productionYear)" case .episode: return getEpisodeLocator() default: @@ -41,4 +53,13 @@ extension BaseItemDto: PortraitImageStackable { let initials = name.split(separator: " ").compactMap({ String($0).first }) return String(initials) } + + public var showTitle: Bool { + switch self.itemType { + case .episode, .series, .movie: + return Defaults[.showPosterLabels] + default: + return true + } + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index 6068f539..4ee051d4 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -84,6 +84,11 @@ extension BaseItemDto { var subtitle: String? = nil + // MARK: Attach media content to self + + var modifiedSelfItem = self + modifiedSelfItem.mediaStreams = mediaSource.mediaStreams + // TODO: other forms of media subtitle if self.itemType == .episode { if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { @@ -101,8 +106,8 @@ extension BaseItemDto { let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode - let videoPlayerViewModel = VideoPlayerViewModel(item: self, - title: self.name ?? "", + let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem, + title: modifiedSelfItem.name ?? "", subtitle: subtitle, streamURL: streamURL.url!, hlsURL: hlsURL.url!, diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 370635dc..24f689b7 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -156,9 +156,9 @@ public extension BaseItemDto { return text } - func getItemProgressString() -> String { + func getItemProgressString() -> String? { if userData?.playbackPositionTicks == nil || userData?.playbackPositionTicks == 0 { - return "" + return nil } let remainingSecs = ((runTimeTicks ?? 0) - (userData?.playbackPositionTicks ?? 0)) / 10_000_000 @@ -208,4 +208,60 @@ public extension BaseItemDto { return getPrimaryImage(maxWidth: maxWidth) } } + + // MARK: ItemDetail + + struct ItemDetail { + let title: String + let content: String + } + + func createInformationItems() -> [ItemDetail] { + var informationItems: [ItemDetail] = [] + + if let productionYear = productionYear { + informationItems.append(ItemDetail(title: "Released", content: "\(productionYear)")) + } + + if let rating = officialRating { + informationItems.append(ItemDetail(title: "Rated", content: "\(rating)")) + } + + if let runtime = getItemRuntime() { + informationItems.append(ItemDetail(title: "Runtime", content: runtime)) + } + + return informationItems + } + + func createMediaItems() -> [ItemDetail] { + var mediaItems: [ItemDetail] = [] + + if let container = container { + let containerList = container.split(separator: ",").joined(separator: ", ") + + if containerList.count > 1 { + mediaItems.append(ItemDetail(title: "Containers", content: containerList)) + } else { + mediaItems.append(ItemDetail(title: "Container", content: containerList)) + } + } + + if let mediaStreams = mediaStreams { + let audioStreams = mediaStreams.filter({ $0.type == .audio }) + let subtitleStreams = mediaStreams.filter({ $0.type == .subtitle }) + + if !audioStreams.isEmpty { + let audioList = audioStreams.compactMap({ $0.displayTitle }).joined(separator: ", ") + mediaItems.append(ItemDetail(title: "Audio", content: audioList)) + } + + if !subtitleStreams.isEmpty { + let subtitleList = subtitleStreams.compactMap({ "\($0.displayTitle ?? "No Title") (\($0.codec ?? "No Codec"))" }).joined(separator: ", ") + mediaItems.append(ItemDetail(title: "Subtitles", content: subtitleList)) + } + } + + return mediaItems + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift index dc2f1c98..2c7cc7af 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift @@ -60,6 +60,10 @@ extension BaseItemPerson { // MARK: PortraitImageStackable extension BaseItemPerson: PortraitImageStackable { + public var portraitImageID: String { + return (id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials + } + public func imageURLContsructor(maxWidth: Int) -> URL { return self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth) } @@ -68,7 +72,7 @@ extension BaseItemPerson: PortraitImageStackable { return self.name ?? "" } - public var description: String? { + public var subtitle: String? { return self.firstRole() } @@ -81,6 +85,10 @@ extension BaseItemPerson: PortraitImageStackable { let initials = name.split(separator: " ").compactMap({ String($0).first }) return String(initials) } + + public var showTitle: Bool { + return true + } } // MARK: DiplayedType diff --git a/Shared/Objects/PortraitImageStackable.swift b/Shared/Objects/PortraitImageStackable.swift index e866de31..ceb98529 100644 --- a/Shared/Objects/PortraitImageStackable.swift +++ b/Shared/Objects/PortraitImageStackable.swift @@ -12,7 +12,9 @@ import Foundation public protocol PortraitImageStackable { func imageURLContsructor(maxWidth: Int) -> URL var title: String { get } - var description: String? { get } + var subtitle: String? { get } var blurHash: String { get } var failureInitials: String { get } + var portraitImageID: String { get } + var showTitle: Bool { get } } diff --git a/Shared/Objects/PosterSize.swift b/Shared/Objects/PosterSize.swift new file mode 100644 index 00000000..799a27a7 --- /dev/null +++ b/Shared/Objects/PosterSize.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 Foundation + +enum PosterSize { + case small + case normal +} diff --git a/Shared/Singleton/SwiftfinNotificationCenter.swift b/Shared/Singleton/SwiftfinNotificationCenter.swift index a5d8407f..a9382880 100644 --- a/Shared/Singleton/SwiftfinNotificationCenter.swift +++ b/Shared/Singleton/SwiftfinNotificationCenter.swift @@ -21,5 +21,7 @@ enum SwiftfinNotificationCenter { static let processDeepLink = Notification.Name("processDeepLink") static let didPurge = Notification.Name("didPurge") static let didChangeServerCurrentURI = Notification.Name("didChangeCurrentLoginURI") + + static let didEndPlayback = Notification.Name("didEndPlayback") } } diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 9a9400be..5805882b 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -38,6 +38,11 @@ extension Defaults.Keys { static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) + // Customize settings + static let showPosterLabels = Key("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let showCastAndCrew = Key("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite) + + // Video player / overlay settings static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) diff --git a/Shared/ViewModels/EpisodeItemViewModel.swift b/Shared/ViewModels/EpisodeItemViewModel.swift index a51b92b2..00d11973 100644 --- a/Shared/ViewModels/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/EpisodeItemViewModel.swift @@ -15,12 +15,12 @@ import Stinsen final class EpisodeItemViewModel: ItemViewModel { @RouterObject var itemRouter: ItemCoordinator.Router? - var seasonEpisodes: [BaseItemDto] = [] + @Published var series: BaseItemDto? override init(item: BaseItemDto) { super.init(item: item) - getSeasonEpisodes() + getEpisodeSeries() } override func getItemDisplayName() -> String { @@ -32,41 +32,15 @@ final class EpisodeItemViewModel: ItemViewModel { return false } - func routeToSeasonItem() { - guard let id = item.seasonId else { return } - UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] item in - self?.itemRouter?.route(to: \.item, item) - }) - .store(in: &cancellables) - } - - func routeToSeriesItem() { + func getEpisodeSeries() { guard let id = item.seriesId else { return } UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in self?.handleAPIRequestError(completion: completion) }, receiveValue: { [weak self] item in - self?.itemRouter?.route(to: \.item, item) + self?.series = item }) .store(in: &cancellables) } - - private func getSeasonEpisodes() { - guard let seriesID = item.seriesId else { return } - TvShowsAPI.getEpisodes(seriesId: seriesID, - userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - seasonId: item.seasonId ?? "") - .sink { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - } receiveValue: { [weak self] item in - self?.seasonEpisodes = item.items ?? [] - } - .store(in: &cancellables) - } } diff --git a/Shared/ViewModels/EpisodesRowViewModel.swift b/Shared/ViewModels/EpisodesRowViewModel.swift new file mode 100644 index 00000000..19874d51 --- /dev/null +++ b/Shared/ViewModels/EpisodesRowViewModel.swift @@ -0,0 +1,65 @@ +// + /* + * 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 JellyfinAPI +import SwiftUI + +final class EpisodesRowViewModel: ViewModel { + + @ObservedObject var episodeItemViewModel: EpisodeItemViewModel + @Published var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] + @Published var selectedSeason: BaseItemDto? { + willSet { + if seasonsEpisodes[newValue!]!.isEmpty { + retrieveEpisodesForSeason(newValue!) + } + } + } + + init(episodeItemViewModel: EpisodeItemViewModel) { + self.episodeItemViewModel = episodeItemViewModel + super.init() + + retrieveSeasons() + } + + private func retrieveSeasons() { + TvShowsAPI.getSeasons(seriesId: episodeItemViewModel.item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { response in + let seasons = response.items ?? [] + seasons.forEach { season in + self.seasonsEpisodes[season] = [] + + if season.id == self.episodeItemViewModel.item.seasonId ?? "" { + self.selectedSeason = season + } + } + } + .store(in: &cancellables) + } + + private func retrieveEpisodesForSeason(_ season: BaseItemDto) { + guard let seasonID = season.id else { return } + + TvShowsAPI.getEpisodes(seriesId: episodeItemViewModel.item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + seasonId: seasonID) + .trackActivity(loading) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { episodes in + self.seasonsEpisodes[season] = episodes.items ?? [] + } + .store(in: &cancellables) + } +} diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 6143c459..a162a6fb 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -32,9 +32,14 @@ final class HomeViewModel: ViewModel { let nc = SwiftfinNotificationCenter.main nc.addObserver(self, selector: #selector(didSignIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) nc.addObserver(self, selector: #selector(didSignOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) + nc.addObserver(self, selector: #selector(didEndPlayback), name: SwiftfinNotificationCenter.Keys.didEndPlayback, object: nil) + } + + deinit { + SwiftfinNotificationCenter.main.removeObserver(self) } - @objc func didSignIn() { + @objc private func didSignIn() { for cancellable in cancellables { cancellable.cancel() } @@ -47,16 +52,29 @@ final class HomeViewModel: ViewModel { refresh() } - @objc func didSignOut() { + @objc private func didSignOut() { for cancellable in cancellables { cancellable.cancel() } cancellables.removeAll() } + + @objc private func didEndPlayback() { + refreshResumeItems() + refreshNextUpItems() + } - func refresh() { + @objc func refresh() { LogManager.shared.log.debug("Refresh called.") + + refreshLibrariesLatest() + refreshResumeItems() + refreshNextUpItems() + } + + // MARK: Libraries Latest Items + private func refreshLibrariesLatest() { UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) .trackActivity(loading) .sink(receiveCompletion: { completion in @@ -101,7 +119,10 @@ final class HomeViewModel: ViewModel { .store(in: &self.cancellables) }) .store(in: &cancellables) - + } + + // MARK: Resume Items + private func refreshResumeItems() { ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], mediaTypes: ["Video"], @@ -121,7 +142,10 @@ final class HomeViewModel: ViewModel { self.resumeItems = response.items ?? [] }) .store(in: &cancellables) - + } + + // MARK: Next Up Items + private func refreshNextUpItems() { TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters]) .trackActivity(loading) diff --git a/Shared/ViewModels/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift index 0a0b6664..d37ec64d 100644 --- a/Shared/ViewModels/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel.swift @@ -19,6 +19,8 @@ class ItemViewModel: ViewModel { @Published var similarItems: [BaseItemDto] = [] @Published var isWatched = false @Published var isFavorited = false + @Published var informationItems: [BaseItemDto.ItemDetail] + @Published var mediaItems: [BaseItemDto.ItemDetail] var itemVideoPlayerViewModel: VideoPlayerViewModel? init(item: BaseItemDto) { @@ -29,6 +31,9 @@ class ItemViewModel: ViewModel { self.playButtonItem = item default: () } + + informationItems = item.createInformationItems() + mediaItems = item.createMediaItems() isFavorited = item.userData?.isFavorite ?? false isWatched = item.userData?.played ?? false @@ -41,12 +46,17 @@ class ItemViewModel: ViewModel { self.handleAPIRequestError(completion: completion) } receiveValue: { videoPlayerViewModel in self.itemVideoPlayerViewModel = videoPlayerViewModel + self.mediaItems = videoPlayerViewModel.item.createMediaItems() } .store(in: &cancellables) } func playButtonText() -> String { - return item.getItemProgressString() == "" ? L10n.play : item.getItemProgressString() + if let itemProgressString = item.getItemProgressString() { + return itemProgressString + } + + return L10n.play } func getItemDisplayName() -> String {