diff --git a/Shared/Components/AttributeBadge.swift b/Shared/Components/AttributeBadge.swift new file mode 100644 index 00000000..9b721b3a --- /dev/null +++ b/Shared/Components/AttributeBadge.swift @@ -0,0 +1,158 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct AttributeBadge: View { + + @Environment(\.font) + private var font + + enum AttributeStyle { + case fill + case outline + } + + private let style: AttributeStyle + private let content: () -> Content + + private var usedFont: Font { + font ?? .caption.weight(.semibold) + } + + @ViewBuilder + private var innerBody: some View { + if style == .fill { + content() + .padding(.init(vertical: 1, horizontal: 4)) + .hidden() + .background { + Color(UIColor.lightGray) + .cornerRadius(2) + .inverseMask { + content() + .padding(.init(vertical: 1, horizontal: 4)) + } + } + } else { + content() + .foregroundStyle(Color(UIColor.lightGray)) + .padding(.init(vertical: 1, horizontal: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color(UIColor.lightGray), lineWidth: 1) + ) + } + } + + var body: some View { + innerBody + .labelStyle(AttributeBadgeLabelStyle()) + .font(usedFont) + } +} + +extension AttributeBadge where Content == Text { + + init( + style: AttributeStyle, + title: @autoclosure @escaping () -> Text + ) { + self.init(style: style) { + title() + } + } + + init( + style: AttributeStyle, + title: String + ) { + self.init(style: style) { + Text(title) + } + } +} + +extension AttributeBadge where Content == Label { + + init( + style: AttributeStyle, + title: String, + image: Image + ) { + self.style = style + self.content = { + Label { Text(title) } icon: { image } + } + } + + init( + style: AttributeStyle, + title: String, + image: @escaping () -> Image + ) { + self.style = style + self.content = { + Label { Text(title) } icon: { image() } + } + } + + init( + style: AttributeStyle, + title: String, + systemName: String + ) { + self.style = style + self.content = { + Label { Text(title) } icon: { Image(systemName: systemName) } + } + } + + init( + style: AttributeStyle, + title: Text, + image: Image + ) { + self.style = style + self.content = { + Label { title } icon: { image } + } + } + + init( + style: AttributeStyle, + title: Text, + image: @escaping () -> Image + ) { + self.style = style + self.content = { + Label { title } icon: { image() } + } + } + + init( + style: AttributeStyle, + title: Text, + systemName: String + ) { + self.style = style + self.content = { + Label { title } icon: { Image(systemName: systemName) } + } + } +} + +private struct AttributeBadgeLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 2) { + configuration.icon + + configuration.title + } + } +} diff --git a/Shared/Extensions/JellyfinAPI/MediaStream.swift b/Shared/Extensions/JellyfinAPI/MediaStream.swift index c9652a6a..117f53f4 100644 --- a/Shared/Extensions/JellyfinAPI/MediaStream.swift +++ b/Shared/Extensions/JellyfinAPI/MediaStream.swift @@ -265,6 +265,14 @@ extension [MediaStream] { contains { $0.isHDVideo } } + var hasHDRVideo: Bool { + contains { VideoRangeType(from: $0.videoRangeType).isHDR } + } + + var hasDolbyVision: Bool { + contains { VideoRangeType(from: $0.videoRangeType).isDolbyVision } + } + var hasSubtitles: Bool { contains { $0.type == .subtitle } } diff --git a/Shared/Extensions/VideoRangeType.swift b/Shared/Extensions/VideoRangeType.swift new file mode 100644 index 00000000..6bd4c36a --- /dev/null +++ b/Shared/Extensions/VideoRangeType.swift @@ -0,0 +1,77 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Foundation + +// TODO: 10.10+ Replace with extension of https://github.com/jellyfin/jellyfin-sdk-swift/blob/main/Sources/Entities/VideoRangeType.swift +enum VideoRangeType: String, Displayable { + /// Unknown video range type. + case unknown = "Unknown" + /// SDR video range type (8bit). + case sdr = "SDR" + /// HDR10 video range type (10bit). + case hdr10 = "HDR10" + /// HLG video range type (10bit). + case hlg = "HLG" + /// Dolby Vision video range type (10bit encoded / 12bit remapped). + case dovi = "DOVI" + /// Dolby Vision with HDR10 video range fallback (10bit). + case doviWithHDR10 = "DOVIWithHDR10" + /// Dolby Vision with HLG video range fallback (10bit). + case doviWithHLG = "DOVIWithHLG" + /// Dolby Vision with SDR video range fallback (8bit / 10bit). + case doviWithSDR = "DOVIWithSDR" + /// HDR10+ video range type (10bit to 16bit). + case hdr10Plus = "HDR10Plus" + + /// Initializes from an optional string, defaulting to `.unknown` if nil or invalid. + init(from rawValue: String?) { + self = VideoRangeType(rawValue: rawValue ?? "") ?? .unknown + } + + /// Returns a human-readable display title for each video range type. + /// Dolby Vision is a proper noun so it is not localized + var displayTitle: String { + switch self { + case .unknown: + return L10n.unknown + case .dovi: + return "Dolby Vision" + case .doviWithHDR10: + return "Dolby Vision / HDR10" + case .doviWithHLG: + return "Dolby Vision / HLG" + case .doviWithSDR: + return "Dolby Vision / SDR" + case .hdr10Plus: + return "HDR10+" + default: + return self.rawValue + } + } + + /// Returns `true` if the video format is HDR (including Dolby Vision). + var isHDR: Bool { + switch self { + case .unknown, .sdr: + return false + default: + return true + } + } + + /// Returns `true` if the video format is Dolby Vision. + var isDolbyVision: Bool { + switch self { + case .dovi, .doviWithHDR10, .doviWithHLG, .doviWithSDR: + return true + default: + return false + } + } +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift deleted file mode 100644 index dfcfa7e2..00000000 --- a/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: remove as a `ViewModifier` and instead a wrapper view - -struct AttributeViewModifier: ViewModifier { - - enum Style { - case fill - case outline - } - - let style: Style - - func body(content: Content) -> some View { - if style == .fill { - content - .font(.caption.weight(.semibold)) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .hidden() - .background { - Color(UIColor.lightGray) - .cornerRadius(2) - .inverseMask { - content - .font(.caption.weight(.semibold)) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - } - } - } else { - content - .font(.caption.weight(.semibold)) - .foregroundColor(Color(UIColor.lightGray)) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color(UIColor.lightGray), lineWidth: 1) - ) - } - } -} diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 7a663a1b..6541736b 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -266,10 +266,6 @@ extension View { } } - func asAttributeStyle(_ style: AttributeViewModifier.Style) -> some View { - modifier(AttributeViewModifier(style: style)) - } - // TODO: rename `blurredFullScreenCover` func blurFullScreenCover( isPresented: Binding, diff --git a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift index 31cfc379..96c7d393 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift @@ -17,12 +17,16 @@ extension ItemView { private var itemViewAttributes var body: some View { - HStack(spacing: 25) { - ForEach(itemViewAttributes, id: \.self) { attribute in - getAttribute(attribute) + if itemViewAttributes.isNotEmpty { + HStack(spacing: 25) { + ForEach(itemViewAttributes, id: \.self) { attribute in + getAttribute(attribute) + .fixedSize(horizontal: true, vertical: false) + } } + .lineLimit(1) + .foregroundStyle(Color(UIColor.darkGray)) } - .foregroundStyle(Color(UIColor.darkGray)) } @ViewBuilder @@ -30,58 +34,52 @@ extension ItemView { switch attribute { case .ratingCritics: if let criticRating = viewModel.item.criticRating { - HStack(spacing: 2) { - Group { - if criticRating >= 60 { - Image(.tomatoFresh) - .symbolRenderingMode(.hierarchical) - } else { - Image(.tomatoRotten) - } + AttributeBadge( + style: .outline, + title: Text("\(criticRating, specifier: "%.0f")") + ) { + if criticRating >= 60 { + Image(.tomatoFresh) + .symbolRenderingMode(.hierarchical) + } else { + Image(.tomatoRotten) } - .font(.caption2) - - Text("\(criticRating, specifier: "%.0f")") } - .asAttributeStyle(.outline) } case .ratingCommunity: if let communityRating = viewModel.item.communityRating { - HStack(spacing: 2) { - Image(systemName: "star.fill") - .font(.caption2) - - Text("\(communityRating, specifier: "%.1f")") - } - .asAttributeStyle(.outline) + AttributeBadge( + style: .outline, + title: Text("\(communityRating, specifier: "%.01f")"), + systemName: "star.fill" + ) } case .ratingOfficial: if let officialRating = viewModel.item.officialRating { - Text(officialRating) - .asAttributeStyle(.outline) + AttributeBadge(style: .outline, title: officialRating) } case .videoQuality: - if viewModel.selectedMediaSource?.mediaStreams?.hasHDVideo == true { - Text("HD") - .asAttributeStyle(.fill) - } if viewModel.selectedMediaSource?.mediaStreams?.has4KVideo == true { - Text("4K") - .asAttributeStyle(.fill) + AttributeBadge(style: .fill, title: "4K") + } else if viewModel.selectedMediaSource?.mediaStreams?.hasHDVideo == true { + AttributeBadge(style: .fill, title: "HD") + } + if viewModel.selectedMediaSource?.mediaStreams?.hasDolbyVision == true { + AttributeBadge(style: .fill, title: "DV") + } + if viewModel.selectedMediaSource?.mediaStreams?.hasHDRVideo == true { + AttributeBadge(style: .fill, title: "HDR") } case .audioChannels: if viewModel.selectedMediaSource?.mediaStreams?.has51AudioChannelLayout == true { - Text("5.1") - .asAttributeStyle(.fill) + AttributeBadge(style: .fill, title: "5.1") } if viewModel.selectedMediaSource?.mediaStreams?.has71AudioChannelLayout == true { - Text("7.1") - .asAttributeStyle(.fill) + AttributeBadge(style: .fill, title: "7.1") } case .subtitles: if viewModel.selectedMediaSource?.mediaStreams?.hasSubtitles == true { - Text("CC") - .asAttributeStyle(.outline) + AttributeBadge(style: .outline, title: "CC") } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index a89bd115..22f479e8 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -9,6 +9,10 @@ /* Begin PBXBuildFile section */ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; + 4E00DCB42D7F7D0900DC3CBB /* VideoRangeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E00DCB32D7F7D0300DC3CBB /* VideoRangeType.swift */; }; + 4E00DCB52D7F7D0900DC3CBB /* VideoRangeType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E00DCB32D7F7D0300DC3CBB /* VideoRangeType.swift */; }; + 4E00DCB72D7F93F000DC3CBB /* AttributeBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E00DCB62D7F93ED00DC3CBB /* AttributeBadge.swift */; }; + 4E00DCB82D7F94CA00DC3CBB /* AttributeBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E00DCB62D7F93ED00DC3CBB /* AttributeBadge.swift */; }; 4E01446C2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; }; 4E01446D2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; }; 4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0195E32CE04678007844F4 /* ItemSection.swift */; }; @@ -178,6 +182,7 @@ 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; 4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; }; 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; }; + 4E9DDBEF2D81EB9E0001C562 /* WrappingHStack in Frameworks */ = {isa = PBXBuildFile; productRef = 4E9DDBEE2D81EB9E0001C562 /* WrappingHStack */; }; 4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */; }; 4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; }; 4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */; }; @@ -950,10 +955,8 @@ E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D9288747230022598C /* ActionButtonHStack.swift */; }; E18E01FA288747580022598C /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01F3288747580022598C /* AboutAppView.swift */; }; E18E0204288749200022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; }; - E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeStyleModifier.swift */; }; E18E0208288749200022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; }; E18E021C2887492B0022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; }; - E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeStyleModifier.swift */; }; E18E021E2887492B0022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; }; E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; @@ -1275,6 +1278,8 @@ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; + 4E00DCB32D7F7D0300DC3CBB /* VideoRangeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRangeType.swift; sourceTree = ""; }; + 4E00DCB62D7F93ED00DC3CBB /* AttributeBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeBadge.swift; sourceTree = ""; }; 4E01446B2D0292E000193038 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; 4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = ""; }; 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = ""; }; @@ -1930,7 +1935,6 @@ E18E01D9288747230022598C /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = ""; }; E18E01F3288747580022598C /* AboutAppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = ""; }; E18E01FF288749200022598C /* RowDivider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowDivider.swift; sourceTree = ""; }; - E18E0202288749200022598C /* AttributeStyleModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeStyleModifier.swift; sourceTree = ""; }; E18E0203288749200022598C /* BlurView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = ""; }; E19070482C84F2BB0004600E /* ButtonStyle-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ButtonStyle-iOS.swift"; sourceTree = ""; }; E190704B2C858CEB0004600E /* VideoPlayerType+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Shared.swift"; sourceTree = ""; }; @@ -2194,6 +2198,7 @@ E15210582946DF1B00375CC2 /* PulseUI in Frameworks */, E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */, 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */, + 4E9DDBEF2D81EB9E0001C562 /* WrappingHStack in Frameworks */, 62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */, E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */, 62666E0627E5017A00EC0ECD /* CoreVideo.framework in Frameworks */, @@ -3926,6 +3931,7 @@ E17AC9692954D00E003D2BC2 /* URLResponse.swift */, E19D41AF2BF2B7540082B8B2 /* URLSessionConfiguration.swift */, E1A1528128FD126C00600579 /* VerticalAlignment.swift */, + 4E00DCB32D7F7D0300DC3CBB /* VideoRangeType.swift */, E11895A22893409D0042947B /* ViewExtensions */, ); path = Extensions; @@ -4654,7 +4660,6 @@ E170D101294CE4C10017224C /* Modifiers */ = { isa = PBXGroup; children = ( - E18E0202288749200022598C /* AttributeStyleModifier.swift */, E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */, E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */, 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */, @@ -5057,6 +5062,7 @@ children = ( E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */, E104DC952B9E7E29008F506D /* AssertionFailureView.swift */, + 4E00DCB62D7F93ED00DC3CBB /* AttributeBadge.swift */, E18E0203288749200022598C /* BlurView.swift */, E145EB212BDCCA43003BF6F3 /* BulletedList.swift */, 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */, @@ -5533,6 +5539,7 @@ E176EBE82D050925009F4CF1 /* CollectionVGrid */, E1A09F712D05933D00835265 /* CollectionVGrid */, E1A09F742D05935100835265 /* CollectionHStack */, + 4E9DDBEE2D81EB9E0001C562 /* WrappingHStack */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -5636,6 +5643,7 @@ E176EBDC2D050067009F4CF1 /* XCRemoteSwiftPackageReference "swift-identified-collections" */, E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */, E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */, + 4E9DDBED2D81E9810001C562 /* XCRemoteSwiftPackageReference "WrappingHStack" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -6065,6 +6073,7 @@ 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */, E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */, E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */, + 4E00DCB72D7F93F000DC3CBB /* AttributeBadge.swift in Sources */, E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */, E1CAF65E2BA345830087D991 /* MediaType.swift in Sources */, E1F5CF062CB09EA000607465 /* CurrentDate.swift in Sources */, @@ -6098,6 +6107,7 @@ E1575E69293E77B5001665B1 /* ItemSortBy.swift in Sources */, E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */, 4E656C312D0798AA00F993F3 /* ParentalRating.swift in Sources */, + 4E00DCB52D7F7D0900DC3CBB /* VideoRangeType.swift in Sources */, E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */, E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, @@ -6286,7 +6296,6 @@ E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */, E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */, E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, - E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */, E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */, 4EB132F02D2CF6D600B5A8E5 /* ImageType.swift in Sources */, E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, @@ -6397,6 +6406,7 @@ E1E1644128BB301900323B0A /* Array.swift in Sources */, E18CE0AF28A222240092E7F1 /* PublicUserRow.swift in Sources */, E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */, + 4E00DCB82D7F94CA00DC3CBB /* AttributeBadge.swift in Sources */, E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */, E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, @@ -6606,7 +6616,6 @@ E19D41A72BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift in Sources */, E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */, E15D63EF2BD6DFC200AA665D /* SystemImageable.swift in Sources */, - E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */, E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */, 4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */, 4E45939E2D04E20000E277E1 /* ItemImagesViewModel.swift in Sources */, @@ -6835,6 +6844,7 @@ E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */, E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */, 4E2AC4C22C6C491200DD600D /* AudoCodec.swift in Sources */, + 4E00DCB42D7F7D0900DC3CBB /* VideoRangeType.swift in Sources */, 4E49DED52CE54D9D00352DCD /* LoginFailurePolicy.swift in Sources */, E10B1EC72BD9AF6100A92EAF /* V2ServerModel.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */, @@ -7606,6 +7616,14 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ + 4E9DDBED2D81E9810001C562 /* XCRemoteSwiftPackageReference "WrappingHStack" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/dkk/WrappingHStack"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.2.11; + }; + }; 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; @@ -7793,6 +7811,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 4E9DDBEE2D81EB9E0001C562 /* WrappingHStack */ = { + isa = XCSwiftPackageProductDependency; + package = 4E9DDBED2D81E9810001C562 /* XCRemoteSwiftPackageReference "WrappingHStack" */; + productName = WrappingHStack; + }; 6220D0C826D63F3700B8E046 /* Stinsen */ = { isa = XCSwiftPackageProductDependency; package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index dd783e02..1d3100f1 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b7189175c8066640649da818750e83deee8ef2f766db25c34025f23d451b301d", + "originHash" : "06a5dcccf9b916d25a75602bcb345218714cc642e54ef793a56ac6adc5439df8", "pins" : [ { "identity" : "blurhashkit", @@ -252,6 +252,15 @@ "branch" : "main", "revision" : "50d4f6ec05a2d8333952def0d8e45019a4207132" } + }, + { + "identity" : "wrappinghstack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/dkk/WrappingHStack", + "state" : { + "revision" : "425d9488ba55f58f0b34498c64c054c77fc2a44b", + "version" : "2.2.11" + } } ], "version" : 3 diff --git a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift index db586a39..a6add7c2 100644 --- a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift +++ b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift @@ -7,6 +7,7 @@ // import SwiftUI +import WrappingHStack extension ItemView { struct AttributesHStack: View { @@ -16,74 +17,146 @@ extension ItemView { @StoredValue(.User.itemViewAttributes) private var itemViewAttributes + // MARK: - Body + var body: some View { - HStack { - ForEach(itemViewAttributes, id: \.self) { attribute in - getAttribute(attribute) + let badges = computeBadges() + if !badges.isEmpty { + WrappingHStack(badges, id: \.self, alignment: .center, spacing: .constant(8), lineSpacing: 8) { badgeItem in + badgeItem + .fixedSize(horizontal: true, vertical: false) } + .foregroundStyle(Color(UIColor.darkGray)) + .lineLimit(1) + .frame(maxWidth: 300) } - .foregroundStyle(Color(UIColor.darkGray)) } - @ViewBuilder - func getAttribute(_ attribute: ItemViewAttribute) -> some View { - switch attribute { - case .ratingCritics: - if let criticRating = viewModel.item.criticRating { - HStack(spacing: 2) { - Group { - if criticRating >= 60 { - Image(.tomatoFresh) - .symbolRenderingMode(.hierarchical) - } else { - Image(.tomatoRotten) + // MARK: - Compute Badges + + private func computeBadges() -> [AnyView] { + var badges: [AnyView] = [] + var processedGroups = Set() + + for attribute in itemViewAttributes { + + if processedGroups.contains(attribute) { continue } + processedGroups.insert(attribute) + + switch attribute { + case .ratingCritics: + if let criticRating = viewModel.item.criticRating { + let badge = AnyView( + AttributeBadge( + style: .outline, + title: Text("\(criticRating, specifier: "%.0f")") + ) { + if criticRating >= 60 { + Image(.tomatoFresh) + .symbolRenderingMode(.hierarchical) + } else { + Image(.tomatoRotten) + } } + ) + badges.append(badge) + } + case .ratingCommunity: + if let communityRating = viewModel.item.communityRating { + let badge = AnyView( + AttributeBadge( + style: .outline, + title: Text("\(communityRating, specifier: "%.01f")"), + systemName: "star.fill" + ) + ) + badges.append(badge) + } + case .ratingOfficial: + if let officialRating = viewModel.item.officialRating { + let badge = AnyView( + AttributeBadge( + style: .outline, + title: officialRating + ) + ) + badges.append(badge) + } + case .videoQuality: + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { + // Resolution badge (if available). Only one of 4K or HD is shown. + if mediaStreams.has4KVideo { + let badge = AnyView( + AttributeBadge( + style: .fill, + title: "4K" + ) + ) + badges.append(badge) + } else if mediaStreams.hasHDVideo { + let badge = AnyView( + AttributeBadge( + style: .fill, + title: "HD" + ) + ) + badges.append(badge) + } + if mediaStreams.hasDolbyVision { + let badge = AnyView( + AttributeBadge( + style: .fill, + title: "DV" + ) + ) + badges.append(badge) + } + if mediaStreams.hasHDRVideo { + let badge = AnyView( + AttributeBadge( + style: .fill, + title: "HDR" + ) + ) + badges.append(badge) } - .font(.caption2) - - Text("\(criticRating, specifier: "%.0f")") } - .asAttributeStyle(.outline) - } - case .ratingCommunity: - if let communityRating = viewModel.item.communityRating { - HStack(spacing: 2) { - Image(systemName: "star.fill") - .font(.caption2) - - Text("\(communityRating, specifier: "%.1f")") + case .audioChannels: + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { + if mediaStreams.has51AudioChannelLayout { + let badge = AnyView( + AttributeBadge( + style: .fill, + title: "5.1" + ) + ) + badges.append(badge) + } + if mediaStreams.has71AudioChannelLayout { + let badge = AnyView( + AttributeBadge( + style: .fill, + title: "7.1" + ) + ) + badges.append(badge) + } + } + case .subtitles: + if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams, + mediaStreams.hasSubtitles + { + let badge = AnyView( + AttributeBadge( + style: .outline, + title: "CC" + ) + ) + badges.append(badge) } - .asAttributeStyle(.outline) - } - case .ratingOfficial: - if let officialRating = viewModel.item.officialRating { - Text(officialRating) - .asAttributeStyle(.outline) - } - case .videoQuality: - if viewModel.selectedMediaSource?.mediaStreams?.hasHDVideo == true { - Text("HD") - .asAttributeStyle(.fill) - } - if viewModel.selectedMediaSource?.mediaStreams?.has4KVideo == true { - Text("4K") - .asAttributeStyle(.fill) - } - case .audioChannels: - if viewModel.selectedMediaSource?.mediaStreams?.has51AudioChannelLayout == true { - Text("5.1") - .asAttributeStyle(.fill) - } - if viewModel.selectedMediaSource?.mediaStreams?.has71AudioChannelLayout == true { - Text("7.1") - .asAttributeStyle(.fill) - } - case .subtitles: - if viewModel.selectedMediaSource?.mediaStreams?.hasSubtitles == true { - Text("CC") - .asAttributeStyle(.outline) } } + return badges } } }