diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index 3e6725be..9fd3089a 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -38,7 +38,8 @@ final class MainCoordinator: NavigationCoordinatable { UIScrollView.appearance().keyboardDismissMode = .onDrag // Back bar button item setup - let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill") + let config = UIImage.SymbolConfiguration(paletteColors: [.white, .jellyfinPurple]) + let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill", withConfiguration: config) let barAppearance = UINavigationBar.appearance() barAppearance.backIndicatorImage = backButtonBackgroundImage barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index a7bef3d4..6e0a6dba 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -42,19 +42,19 @@ extension BaseItemDto { return text } - func getItemProgressString() -> String? { - if userData?.playbackPositionTicks == nil || userData?.playbackPositionTicks == 0 { - return nil - } + var progress: String? { + guard let playbackPositionTicks = userData?.playbackPositionTicks, + let totalTicks = runTimeTicks, + playbackPositionTicks != 0, + totalTicks != 0 else { return nil } - let remainingSecs = ((runTimeTicks ?? 0) - (userData?.playbackPositionTicks ?? 0)) / 10_000_000 - let proghours = Int(remainingSecs / 3600) - let progminutes = Int((Int(remainingSecs) - (proghours * 3600)) / 60) - if proghours != 0 { - return "\(proghours)h \(String(progminutes).leftPad(toWidth: 2, withString: "0"))m" - } else { - return "\(String(progminutes))m" - } + let remainingSeconds = (totalTicks - playbackPositionTicks) / 10_000_000 + + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .abbreviated + + return formatter.string(from: .init(remainingSeconds)) } func getLiveStartTimeString(formatter: DateFormatter) -> String { diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 5a947d4b..56c2886d 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -46,6 +46,9 @@ extension View { } } + // TODO: Simplify plethora of calls + // TODO: Centralize math + // TODO: Move poster stuff to own file func posterStyle(type: PosterType, width: CGFloat) -> some View { Group { switch type { @@ -57,17 +60,35 @@ extension View { } } - /// Applies Portrait Poster frame with proper corner radius ratio against the width - func portraitPoster(width: CGFloat) -> some View { + func posterStyle(type: PosterType, height: CGFloat) -> some View { + Group { + switch type { + case .portrait: + self.portraitPoster(height: height) + case .landscape: + self.landscapePoster(height: height) + } + } + } + + private func portraitPoster(width: CGFloat) -> some View { self.frame(width: width, height: width * 1.5) .cornerRadius((width * 1.5) / 40) } - func landscapePoster(width: CGFloat) -> some View { + private func landscapePoster(width: CGFloat) -> some View { self.frame(width: width, height: width / 1.77) .cornerRadius(width / 30) } + private func portraitPoster(height: CGFloat) -> some View { + self.portraitPoster(width: height / 1.5) + } + + private func landscapePoster(height: CGFloat) -> some View { + self.landscapePoster(width: height * 1.77) + } + @inlinable func padding2(_ edges: Edge.Set = .all) -> some View { self.padding(edges) diff --git a/Shared/ViewModels/EpisodesRowManager.swift b/Shared/ViewModels/EpisodesRowManager.swift index 684064b0..4c4916b6 100644 --- a/Shared/ViewModels/EpisodesRowManager.swift +++ b/Shared/ViewModels/EpisodesRowManager.swift @@ -6,6 +6,7 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import Combine import Defaults import JellyfinAPI import SwiftUI @@ -63,9 +64,10 @@ extension EpisodesRowManager { TvShowsAPI.getEpisodes( seriesId: item.id ?? "", userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + fields: [.overview, .seasonUserData], seasonId: seasonID, - isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false + isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false, + enableUserData: true ) .trackActivity(loading) .sink { completion in diff --git a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift index 911f9ae1..d45f9636 100644 --- a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift @@ -13,20 +13,13 @@ import Stinsen final class EpisodeItemViewModel: ItemViewModel { - @RouterObject - private var itemRouter: ItemCoordinator.Router? @Published - var playButtonText: String = "" - @Published - var mediaDetailItems: [[BaseItemDto.ItemDetail]] = [] + var seriesItem: BaseItemDto? override init(item: BaseItemDto) { super.init(item: item) - $videoPlayerViewModels.sink(receiveValue: { newValue in - self.mediaDetailItems = self.createMediaDetailItems(viewModels: newValue) - }) - .store(in: &cancellables) + getSeriesItem() } override func updateItem() { @@ -56,29 +49,23 @@ final class EpisodeItemViewModel: ItemViewModel { .store(in: &cancellables) } - private func createMediaDetailItems(viewModels: [VideoPlayerViewModel]) -> [[BaseItemDto.ItemDetail]] { - var fileMediaItems: [[BaseItemDto.ItemDetail]] = [] + private func getSeriesItem() { + guard let seriesID = item.seriesId else { return } - for viewModel in viewModels { - - let audioStreams = viewModel.audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } - .joined(separator: ", ") - - let subtitleStreams = viewModel.subtitleStreams - .compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } - .joined(separator: ", ") - - let currentMediaItems: [BaseItemDto.ItemDetail] = [ - .init(title: "File", content: viewModel.filename ?? .emptyDash), - .init(title: "Audio", content: audioStreams), - .init(title: "Subtitles", content: subtitleStreams), - ] - - fileMediaItems.append(currentMediaItems) - } - - // print(fileMediaItems) - - return fileMediaItems + ItemsAPI.getItems( + userId: SessionManager.main.currentLogin.user.id, + limit: 1, + fields: ItemFields.allCases, + enableUserData: true, + ids: [seriesID] + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + guard let firstItem = response.items?.first else { return } + self?.seriesItem = firstItem + }) + .store(in: &cancellables) } } diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index 954eb19c..aa834529 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -70,8 +70,8 @@ class ItemViewModel: ViewModel { } func refreshItemVideoPlayerViewModel(for item: BaseItemDto) { - guard item.type == .episode || item.type == .movie else { return } - guard !item.missing, !item.unaired else { return } + guard item.type == .episode || item.type == .movie, + !item.missing else { return } item.createVideoPlayerViewModel() .sink { completion in @@ -93,7 +93,7 @@ class ItemViewModel: ViewModel { return L10n.missing } - if let itemProgressString = item.getItemProgressString() { + if let itemProgressString = item.progress { return itemProgressString } diff --git a/Shared/Views/LandscapePosterProgressBar.swift b/Shared/Views/LandscapePosterProgressBar.swift new file mode 100644 index 00000000..df73f418 --- /dev/null +++ b/Shared/Views/LandscapePosterProgressBar.swift @@ -0,0 +1,51 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct LandscapePosterProgressBar: View { + + let title: String + let progress: CGFloat + + // Scale padding depending on view width + @State + private var paddingScale: CGFloat = 1.0 + + var body: some View { + GeometryReader { reader in + ZStack(alignment: .bottom) { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.7), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 40) + + VStack(alignment: .leading, spacing: 3 * paddingScale) { + + Spacer() + + Text(title) + .font(.subheadline) + .foregroundColor(.white) + + ProgressBar(progress: progress) + } + .padding(.horizontal, 5 * paddingScale) + .padding(.bottom, 7 * paddingScale) + .onAppear { + paddingScale = reader.size.width / 300 + } + } + } + } +} diff --git a/Shared/Views/ProgressBar.swift b/Shared/Views/ProgressBar.swift new file mode 100644 index 00000000..aa138082 --- /dev/null +++ b/Shared/Views/ProgressBar.swift @@ -0,0 +1,28 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct ProgressBar: View { + + let progress: CGFloat + + var body: some View { + ZStack(alignment: .leading) { + Capsule() + .foregroundColor(.white) + .opacity(0.2) + + Capsule() + .foregroundColor(.jellyfinPurple) + .scaleEffect(x: progress, y: 1, anchor: .leading) + } + .frame(maxWidth: .infinity) + .frame(height: 3) + } +} diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift index b068d985..4ab66426 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift @@ -47,7 +47,7 @@ struct CinematicResumeCardView: View { .ignoresSafeArea() VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? "") + Text(item.progress ?? "") .font(.subheadline) .padding(.vertical, 5) .padding(.leading, 10) diff --git a/Swiftfin tvOS/Components/LandscapeItemElement.swift b/Swiftfin tvOS/Components/LandscapeItemElement.swift index 9bef8c91..f0b4d17e 100644 --- a/Swiftfin tvOS/Components/LandscapeItemElement.swift +++ b/Swiftfin tvOS/Components/LandscapeItemElement.swift @@ -81,7 +81,7 @@ struct LandscapeItemElement: View { .frame(width: 445, height: 90) .mask(CutOffShadow()) VStack(alignment: .leading) { - Text("CONTINUE • \(item.getItemProgressString() ?? "")") + Text("CONTINUE • \(item.progress ?? "")") .font(.caption) .fontWeight(.medium) .offset(y: 5) diff --git a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift index 15c95d8b..712c2c03 100644 --- a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift +++ b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift @@ -34,7 +34,7 @@ struct ContinueWatchingCard: View { } VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? "") + Text(item.progress ?? "") .font(.subheadline) .padding(.vertical, 5) .padding(.leading, 10) diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift index 5634eeaa..d890dc94 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift @@ -41,7 +41,7 @@ extension ItemView { .failure { InitialFailureView(viewModel.item.title.initials) } - .portraitPoster(width: 270) + .posterStyle(type: .portrait, width: 270) AboutViewCard( isShowingAlert: $presentOverviewAlert, diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index d89ed37b..9235afcf 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -525,6 +525,10 @@ E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; }; E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; }; + E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A628C29B720021BC93 /* ProgressBar.swift */; }; + E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A628C29B720021BC93 /* ProgressBar.swift */; }; + E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; }; + E1FE69AB28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -949,6 +953,8 @@ E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemContentView.swift; sourceTree = ""; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; + E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; + E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1446,6 +1452,7 @@ isa = PBXGroup; children = ( E18E01A7288746AF0022598C /* DotHStack.swift */, + E1FE69AF28C2DA4A0021BC93 /* FilterDrawerHStack */, E18E01A5288746AF0022598C /* PillHStack.swift */, E16AA60728A364A6009A983C /* PosterButton.swift */, E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */, @@ -2055,7 +2062,9 @@ E18E01FF288749200022598C /* Divider.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */, E1047E2227E5880000CB0D4A /* InitialFailureView.swift */, + E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */, 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */, + E1FE69A628C29B720021BC93 /* ProgressBar.swift */, E1E1643D28BB074000323B0A /* SelectorView.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */, ); @@ -2074,8 +2083,6 @@ E1C55AB228BD051700A9AD88 /* Components */ = { isa = PBXGroup; children = ( - E113133528BE98AA00930F75 /* FilterDrawerButton.swift */, - E113133328BE988200930F75 /* FilterDrawerHStack.swift */, E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */, ); path = Components; @@ -2190,6 +2197,15 @@ path = Errors; sourceTree = ""; }; + E1FE69AF28C2DA4A0021BC93 /* FilterDrawerHStack */ = { + isa = PBXGroup; + children = ( + E113133528BE98AA00930F75 /* FilterDrawerButton.swift */, + E113133328BE988200930F75 /* FilterDrawerHStack.swift */, + ); + path = FilterDrawerHStack; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -2484,6 +2500,7 @@ E18E021A2887492B0022598C /* AppIcon.swift in Sources */, E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, E126F742278A656C00A522BF /* ServerStreamType.swift in Sources */, + E1FE69AB28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, @@ -2623,6 +2640,7 @@ E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */, E1A2C160279A7DCA005EC829 /* AboutAppView.swift in Sources */, C4BE076A271FC164003F4AD1 /* TVLibrariesView.swift in Sources */, + E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */, E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */, E11CEB8E28999B4A003E74C7 /* FontExtensions.swift in Sources */, C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */, @@ -2763,6 +2781,7 @@ E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, 62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */, + E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, E113133228BDC72000930F75 /* FilterView.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, E11895AC289383EE0042947B /* NavBarOffsetModifier.swift in Sources */, @@ -2803,6 +2822,7 @@ E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */, C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */, E184C160288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */, + E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */, E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */, E18E01E9288747230022598C /* SeriesItemView.swift in Sources */, E18E023A288749540022598C /* UIScrollViewExtensions.swift in Sources */, diff --git a/Swiftfin/Views/LibraryView/Components/FilterDrawerButton.swift b/Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift similarity index 98% rename from Swiftfin/Views/LibraryView/Components/FilterDrawerButton.swift rename to Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift index bb66d794..98cb9eac 100644 --- a/Swiftfin/Views/LibraryView/Components/FilterDrawerButton.swift +++ b/Swiftfin/Components/FilterDrawerHStack/FilterDrawerButton.swift @@ -53,10 +53,10 @@ extension FilterDrawerHStack { .foregroundColor(activated ? .jellyfinPurple : Color(UIColor.secondarySystemFill)) .opacity(0.5) } - .overlay( + .overlay { Capsule() .stroke(activated ? .purple : Color(UIColor.secondarySystemFill), lineWidth: 1) - ) + } } } } diff --git a/Swiftfin/Views/LibraryView/Components/FilterDrawerHStack.swift b/Swiftfin/Components/FilterDrawerHStack/FilterDrawerHStack.swift similarity index 100% rename from Swiftfin/Views/LibraryView/Components/FilterDrawerHStack.swift rename to Swiftfin/Components/FilterDrawerHStack/FilterDrawerHStack.swift diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift index 86d4f700..b379baed 100644 --- a/Swiftfin/Components/PosterButton.swift +++ b/Swiftfin/Components/PosterButton.swift @@ -10,15 +10,15 @@ import SwiftUI struct PosterButton: View { - private let item: Item - private let type: PosterType - private let itemScale: CGFloat - private let horizontalAlignment: HorizontalAlignment - private let content: (Item) -> Content - private let imageOverlay: (Item) -> ImageOverlay - private let contextMenu: (Item) -> ContextMenu - private let onSelect: (Item) -> Void - private let singleImage: Bool + private var item: Item + private var type: PosterType + private var itemScale: CGFloat + private var horizontalAlignment: HorizontalAlignment + private var content: (Item) -> Content + private var imageOverlay: (Item) -> ImageOverlay + private var contextMenu: (Item) -> ContextMenu + private var onSelect: (Item) -> Void + private var singleImage: Bool private var itemWidth: CGFloat { type.width * itemScale @@ -96,34 +96,16 @@ extension PosterButton where Content == PosterButtonDefaultContentView, } extension PosterButton { - @ViewBuilder - func horizontalAlignment(_ alignment: HorizontalAlignment) -> PosterButton { - PosterButton( - item: item, - type: type, - itemScale: itemScale, - horizontalAlignment: alignment, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - onSelect: onSelect, - singleImage: singleImage - ) + func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self { + var copy = self + copy.horizontalAlignment = alignment + return copy } - @ViewBuilder - func scaleItem(_ scale: CGFloat) -> PosterButton { - PosterButton( - item: item, - type: type, - itemScale: scale, - horizontalAlignment: horizontalAlignment, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - onSelect: onSelect, - singleImage: singleImage - ) + func scaleItem(_ scale: CGFloat) -> Self { + var copy = self + copy.itemScale = scale + return copy } @ViewBuilder @@ -171,19 +153,10 @@ extension PosterButton { ) } - @ViewBuilder - func onSelect(_ action: @escaping (Item) -> Void) -> PosterButton { - PosterButton( - item: item, - type: type, - itemScale: itemScale, - horizontalAlignment: horizontalAlignment, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - onSelect: action, - singleImage: singleImage - ) + func onSelect(_ action: @escaping (Item) -> Void) -> Self { + var copy = self + copy.onSelect = action + return copy } } diff --git a/Swiftfin/Components/PosterHStack.swift b/Swiftfin/Components/PosterHStack.swift index acab5396..1c9970c7 100644 --- a/Swiftfin/Components/PosterHStack.swift +++ b/Swiftfin/Components/PosterHStack.swift @@ -6,19 +6,20 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import CollectionView import SwiftUI struct PosterHStack: View { - private let title: String - private let type: PosterType - private let items: [Item] - private let itemScale: CGFloat - private let content: (Item) -> Content - private let imageOverlay: (Item) -> ImageOverlay - private let contextMenu: (Item) -> ContextMenu - private let trailingContent: () -> TrailingContent - private let onSelect: (Item) -> Void + private var title: String + private var type: PosterType + private var items: [Item] + private var itemScale: CGFloat + private var content: (Item) -> Content + private var imageOverlay: (Item) -> ImageOverlay + private var contextMenu: (Item) -> ContextMenu + private var trailingContent: () -> TrailingContent + private var onSelect: (Item) -> Void private init( title: String, @@ -103,19 +104,11 @@ extension PosterHStack where Content == PosterButtonDefaultContentView, } extension PosterHStack { - @ViewBuilder - func scaleItems(_ scale: CGFloat) -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: scale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect - ) + + func scaleItems(_ scale: CGFloat) -> Self { + var copy = self + copy.itemScale = scale + return copy } @ViewBuilder @@ -182,18 +175,9 @@ extension PosterHStack { ) } - @ViewBuilder - func onSelect(_ onSelect: @escaping (Item) -> Void) -> PosterHStack { - PosterHStack( - title: title, - type: type, - items: items, - itemScale: itemScale, - content: content, - imageOverlay: imageOverlay, - contextMenu: contextMenu, - trailingContent: trailingContent, - onSelect: onSelect - ) + func onSelect(_ action: @escaping (Item) -> Void) -> Self { + var copy = self + copy.onSelect = action + return copy } } diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift index 6523fef5..06ff5728 100644 --- a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift +++ b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift @@ -30,35 +30,10 @@ struct ContinueWatchingView: View { } } .imageOverlay { item in - 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() ?? L10n.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) - } - } - } - } + LandscapePosterProgressBar( + title: item.progress ?? L10n.continue, + progress: (item.userData?.playedPercentage ?? 0) / 100 + ) } } } diff --git a/Swiftfin/Views/HomeView/HomeContentView.swift b/Swiftfin/Views/HomeView/HomeContentView.swift index e7c8ca3e..58c0d3f4 100644 --- a/Swiftfin/Views/HomeView/HomeContentView.swift +++ b/Swiftfin/Views/HomeView/HomeContentView.swift @@ -6,6 +6,7 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import CollectionView import Defaults import SwiftUI diff --git a/Swiftfin/Views/ItemView/Components/AboutView.swift b/Swiftfin/Views/ItemView/Components/AboutView.swift index 7fa2192c..47f7124d 100644 --- a/Swiftfin/Views/ItemView/Components/AboutView.swift +++ b/Swiftfin/Views/ItemView/Components/AboutView.swift @@ -35,7 +35,7 @@ extension ItemView { viewModel.item.type == .episode ? viewModel.item.seriesImageSource(.primary, maxWidth: 300) : viewModel .item.imageSource(.primary, maxWidth: 300) ) - .portraitPoster(width: 130) + .posterStyle(type: .portrait, width: 130) .accessibilityIgnoresInvertColors() Button { diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift index fbbd3854..0f302ae4 100644 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift +++ b/Swiftfin/Views/ItemView/Components/ActionButtonHStack.swift @@ -36,7 +36,7 @@ extension ItemView { ) } else { Image(systemName: "checkmark.circle") - .foregroundStyle(.white) +// .foregroundStyle(.white) } } .buttonStyle(PlainButtonStyle()) @@ -54,7 +54,7 @@ extension ItemView { .foregroundStyle(Color.red) } else { Image(systemName: "heart") - .foregroundStyle(.white) +// .foregroundStyle(.white) } } .buttonStyle(PlainButtonStyle()) diff --git a/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift index 48d41a07..3b53fa44 100644 --- a/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift +++ b/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift @@ -9,20 +9,26 @@ import JellyfinAPI import SwiftUI -struct EpisodeCard: View { +struct EpisodeCard: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router @ScaledMetric private var staticOverviewHeight: CGFloat = 50 + let viewModel: RowManager let episode: BaseItemDto var body: some View { PosterButton(item: episode, type: .landscape, singleImage: true) .scaleItem(1.2) .imageOverlay { _ in - if episode.userData?.played ?? false { + if let progress = episode.progress { + LandscapePosterProgressBar( + title: progress, + progress: (episode.userData?.playedPercentage ?? 0) / 100 + ) + } else if episode.userData?.played ?? false { ZStack(alignment: .bottomTrailing) { Color.clear @@ -35,34 +41,53 @@ struct EpisodeCard: View { } } .content { _ in - VStack(alignment: .leading) { - Text(episode.episodeLocator ?? L10n.unknown) - .font(.footnote) - .foregroundColor(.secondary) + Button { + router.route(to: \.item, episode) + } label: { + VStack(alignment: .leading) { + Text(episode.episodeLocator ?? L10n.unknown) + .font(.footnote) + .foregroundColor(.secondary) - Text(episode.displayName) - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) + Text(episode.displayName) + .font(.body) + .foregroundColor(.primary) + .padding(.bottom, 1) + .lineLimit(2) + .multilineTextAlignment(.leading) - ZStack(alignment: .topLeading) { - Color.clear - .frame(height: staticOverviewHeight) + ZStack(alignment: .topLeading) { + Color.clear + .frame(height: staticOverviewHeight) - if episode.unaired { - Text(episode.airDateLabel ?? L10n.noOverviewAvailable) - } else { - Text(episode.overview ?? L10n.noOverviewAvailable) + if episode.unaired { + Text(episode.airDateLabel ?? L10n.noOverviewAvailable) + } else { + Text(episode.overview ?? L10n.noOverviewAvailable) + } } + .font(.caption.weight(.light)) + .foregroundColor(.secondary) + .lineLimit(4) + .multilineTextAlignment(.leading) + + L10n.seeMore.text + .font(.footnote) + .fontWeight(.medium) + .foregroundColor(.jellyfinPurple) } - .font(.caption.weight(.light)) - .foregroundColor(.secondary) - .lineLimit(4) - .multilineTextAlignment(.leading) } } .onSelect { _ in - itemRouter.route(to: \.item, episode) + episode.createVideoPlayerViewModel() + .sink { completion in + self.viewModel.handleAPIRequestError(completion: completion) + } receiveValue: { viewModels in + if let episodeViewModel = viewModels.first { + router.route(to: \.videoPlayer, episodeViewModel) + } + } + .store(in: &viewModel.cancellables) } } } diff --git a/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift b/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift index aa3ef5fb..fc065bd2 100644 --- a/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift +++ b/Swiftfin/Views/ItemView/Components/EpisodesRowView/SeriesEpisodesView.swift @@ -59,16 +59,16 @@ struct SeriesEpisodesView: View { HStack(alignment: .top, spacing: 15) { if viewModel.isLoading { ForEach(0 ..< 5) { _ in - EpisodeCard(episode: .placeHolder) + EpisodeCard(viewModel: viewModel, episode: .placeHolder) .redacted(reason: .placeholder) } } else if let selectedSeason = viewModel.selectedSeason { if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { if seasonEpisodes.isEmpty { - EpisodeCard(episode: .noResults) + EpisodeCard(viewModel: viewModel, episode: .noResults) } else { ForEach(seasonEpisodes) { episode in - EpisodeCard(episode: episode) + EpisodeCard(viewModel: viewModel, episode: episode) .id(episode.id) } } diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift index 2856c6ea..9a7e0b94 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift @@ -14,7 +14,7 @@ extension EpisodeItemView { struct ContentView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router @ObservedObject var viewModel: EpisodeItemViewModel @@ -35,7 +35,7 @@ extension EpisodeItemView { if let itemOverview = viewModel.item.overview { TruncatedTextView(text: itemOverview) { - itemRouter.route(to: \.itemOverview, viewModel.item) + router.route(to: \.itemOverview, viewModel.item) } .font(.footnote) .lineLimit(5) @@ -68,6 +68,15 @@ extension EpisodeItemView { Divider() } + // MARK: Series + + if let seriesItem = viewModel.seriesItem { + PosterHStack(title: L10n.series, type: .portrait, items: [seriesItem]) + .onSelect { item in + router.route(to: \.item, item) + } + } + // MARK: Details if let informationItems = viewModel.item.createInformationItems(), !informationItems.isEmpty { @@ -131,6 +140,7 @@ extension EpisodeItemView.ContentView { ItemView.ActionButtonHStack(viewModel: viewModel) .font(.title) .frame(maxWidth: 300) + .foregroundStyle(.primary) } } } diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift index f6101c9b..3781d560 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemView.swift @@ -26,7 +26,7 @@ struct EpisodeItemView: View { .navBarOffset( $scrollViewOffset, start: 0, - end: 10 + end: 30 ) } } diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift index 88c8050c..269b2bca 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift @@ -152,6 +152,7 @@ extension ItemView.CinematicScrollView { ItemView.ActionButtonHStack(viewModel: viewModel) .font(.title) .frame(maxWidth: 300) + .foregroundColor(.white) } .frame(maxWidth: .infinity) diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift index ac3600a9..8c3775cc 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift @@ -183,6 +183,7 @@ extension ItemView.CompactLogoScrollView { ItemView.ActionButtonHStack(viewModel: viewModel) .font(.title) .frame(maxWidth: 300) + .foregroundColor(.white) } } } diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift index fe460c38..bae8672c 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift @@ -179,7 +179,7 @@ extension ItemView.CompactPosterScrollView { // MARK: Portrait Image ImageView(viewModel.item.imageSource(.primary, maxWidth: 130)) - .portraitPoster(width: 130) + .posterStyle(type: .portrait, width: 130) .accessibilityIgnoresInvertColors() rightShelfView @@ -197,6 +197,7 @@ extension ItemView.CompactPosterScrollView { ItemView.ActionButtonHStack(viewModel: viewModel, equalSpacing: false) .font(.title2) + .foregroundColor(.white) } } } diff --git a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift index fc096aec..50dcc2e6 100644 --- a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift @@ -14,7 +14,7 @@ extension iPadOSEpisodeItemView { struct ContentView: View { @EnvironmentObject - private var itemRouter: ItemCoordinator.Router + private var router: ItemCoordinator.Router @ObservedObject var viewModel: EpisodeItemViewModel @@ -47,6 +47,15 @@ extension iPadOSEpisodeItemView { Divider() } + // MARK: Series + + if let seriesItem = viewModel.seriesItem { + PosterHStack(title: L10n.series, type: .portrait, items: [seriesItem]) + .onSelect { item in + router.route(to: \.item, item) + } + } + ItemView.AboutView(viewModel: viewModel) } } diff --git a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift index 7e536b2c..febafe26 100644 --- a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift @@ -159,6 +159,7 @@ extension ItemView.iPadOSCinematicScrollView { ItemView.ActionButtonHStack(viewModel: viewModel) .font(.title) + .foregroundColor(.white) } .frame(width: 250) } diff --git a/Swiftfin/Views/LibraryView/Components/LibraryItemRow.swift b/Swiftfin/Views/LibraryView/Components/LibraryItemRow.swift index 43140bb5..3efcead2 100644 --- a/Swiftfin/Views/LibraryView/Components/LibraryItemRow.swift +++ b/Swiftfin/Views/LibraryView/Components/LibraryItemRow.swift @@ -21,9 +21,8 @@ struct LibraryItemRow: View { router.route(to: \.item, item) } label: { HStack(alignment: .bottom) { - PosterButton(item: item, type: .portrait) - .scaleItem(0.6) - .content { _ in } + ImageView(item.portraitPosterImageSource(maxWidth: 60)) + .posterStyle(type: .portrait, width: 60) VStack(alignment: .leading) { Text(item.displayName) diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift index 0572ff55..6b15308c 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift @@ -31,15 +31,12 @@ struct CustomizeViewsSettings: View { var recommendedPosterType @Default(.Customization.searchPosterType) var searchPosterType + @Default(.Customization.Library.gridPosterType) + var libraryGridPosterType @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) var useSeriesLandscapeBackdrop - @Default(.Customization.Library.gridPosterType) - var libraryGridPosterType - @Default(.Customization.Library.viewType) - var libraryViewType - var body: some View { List { Section { @@ -94,6 +91,12 @@ struct CustomizeViewsSettings: View { Text(type.localizedName).tag(type.rawValue) } } + + Picker(L10n.library, selection: $libraryGridPosterType) { + ForEach(PosterType.allCases, id: \.self) { type in + Text(type.localizedName).tag(type.rawValue) + } + } } header: { // TODO: localize after organization Text("Posters") @@ -106,23 +109,6 @@ struct CustomizeViewsSettings: View { // TODO: localize after organization Text("Episode Landscape Poster") } - - Section { - Picker(L10n.library, selection: $libraryGridPosterType) { - ForEach(PosterType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) - } - } - - Picker(L10n.items, selection: $libraryViewType) { - ForEach(LibraryViewType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) - } - } - } header: { - // TODO: localize after organization - Text("Library") - } } .navigationTitle(L10n.customize) }