[iOS & tvOS] Video Range Types (#1449)

* `VideoRangeType` "Extension" for now it's an enum until 10.10. Otherwise, done.

* Limit lines to 1 and variable width as needed.

* CodeFactor issue resolution

* AttributeViewModifier -> AttributeBadge

* change API

* `WrappingHStack`

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2025-03-14 11:01:23 -06:00 committed by GitHub
parent 8f21860e5e
commit 890bf1fa31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 447 additions and 153 deletions

View File

@ -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<Content: View>: 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<Text, Image> {
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
}
}
}

View File

@ -265,6 +265,14 @@ extension [MediaStream] {
contains { $0.isHDVideo } contains { $0.isHDVideo }
} }
var hasHDRVideo: Bool {
contains { VideoRangeType(from: $0.videoRangeType).isHDR }
}
var hasDolbyVision: Bool {
contains { VideoRangeType(from: $0.videoRangeType).isDolbyVision }
}
var hasSubtitles: Bool { var hasSubtitles: Bool {
contains { $0.type == .subtitle } contains { $0.type == .subtitle }
} }

View File

@ -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
}
}
}

View File

@ -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)
)
}
}
}

View File

@ -266,10 +266,6 @@ extension View {
} }
} }
func asAttributeStyle(_ style: AttributeViewModifier.Style) -> some View {
modifier(AttributeViewModifier(style: style))
}
// TODO: rename `blurredFullScreenCover` // TODO: rename `blurredFullScreenCover`
func blurFullScreenCover( func blurFullScreenCover(
isPresented: Binding<Bool>, isPresented: Binding<Bool>,

View File

@ -17,12 +17,16 @@ extension ItemView {
private var itemViewAttributes private var itemViewAttributes
var body: some View { var body: some View {
HStack(spacing: 25) { if itemViewAttributes.isNotEmpty {
ForEach(itemViewAttributes, id: \.self) { attribute in HStack(spacing: 25) {
getAttribute(attribute) ForEach(itemViewAttributes, id: \.self) { attribute in
getAttribute(attribute)
.fixedSize(horizontal: true, vertical: false)
}
} }
.lineLimit(1)
.foregroundStyle(Color(UIColor.darkGray))
} }
.foregroundStyle(Color(UIColor.darkGray))
} }
@ViewBuilder @ViewBuilder
@ -30,58 +34,52 @@ extension ItemView {
switch attribute { switch attribute {
case .ratingCritics: case .ratingCritics:
if let criticRating = viewModel.item.criticRating { if let criticRating = viewModel.item.criticRating {
HStack(spacing: 2) { AttributeBadge(
Group { style: .outline,
if criticRating >= 60 { title: Text("\(criticRating, specifier: "%.0f")")
Image(.tomatoFresh) ) {
.symbolRenderingMode(.hierarchical) if criticRating >= 60 {
} else { Image(.tomatoFresh)
Image(.tomatoRotten) .symbolRenderingMode(.hierarchical)
} } else {
Image(.tomatoRotten)
} }
.font(.caption2)
Text("\(criticRating, specifier: "%.0f")")
} }
.asAttributeStyle(.outline)
} }
case .ratingCommunity: case .ratingCommunity:
if let communityRating = viewModel.item.communityRating { if let communityRating = viewModel.item.communityRating {
HStack(spacing: 2) { AttributeBadge(
Image(systemName: "star.fill") style: .outline,
.font(.caption2) title: Text("\(communityRating, specifier: "%.01f")"),
systemName: "star.fill"
Text("\(communityRating, specifier: "%.1f")") )
}
.asAttributeStyle(.outline)
} }
case .ratingOfficial: case .ratingOfficial:
if let officialRating = viewModel.item.officialRating { if let officialRating = viewModel.item.officialRating {
Text(officialRating) AttributeBadge(style: .outline, title: officialRating)
.asAttributeStyle(.outline)
} }
case .videoQuality: case .videoQuality:
if viewModel.selectedMediaSource?.mediaStreams?.hasHDVideo == true {
Text("HD")
.asAttributeStyle(.fill)
}
if viewModel.selectedMediaSource?.mediaStreams?.has4KVideo == true { if viewModel.selectedMediaSource?.mediaStreams?.has4KVideo == true {
Text("4K") AttributeBadge(style: .fill, title: "4K")
.asAttributeStyle(.fill) } 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: case .audioChannels:
if viewModel.selectedMediaSource?.mediaStreams?.has51AudioChannelLayout == true { if viewModel.selectedMediaSource?.mediaStreams?.has51AudioChannelLayout == true {
Text("5.1") AttributeBadge(style: .fill, title: "5.1")
.asAttributeStyle(.fill)
} }
if viewModel.selectedMediaSource?.mediaStreams?.has71AudioChannelLayout == true { if viewModel.selectedMediaSource?.mediaStreams?.has71AudioChannelLayout == true {
Text("7.1") AttributeBadge(style: .fill, title: "7.1")
.asAttributeStyle(.fill)
} }
case .subtitles: case .subtitles:
if viewModel.selectedMediaSource?.mediaStreams?.hasSubtitles == true { if viewModel.selectedMediaSource?.mediaStreams?.hasSubtitles == true {
Text("CC") AttributeBadge(style: .outline, title: "CC")
.asAttributeStyle(.outline)
} }
} }
} }

View File

@ -9,6 +9,10 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; };
091B5A8D268315D400D78B61 /* 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 */; }; 4E01446C2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; };
4E01446D2D0292E200193038 /* 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 */; }; 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 */; }; 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; };
4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; }; 4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; };
4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.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 */; }; 4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */; };
4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; }; 4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; };
4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.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 */; }; E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01D9288747230022598C /* ActionButtonHStack.swift */; };
E18E01FA288747580022598C /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01F3288747580022598C /* AboutAppView.swift */; }; E18E01FA288747580022598C /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01F3288747580022598C /* AboutAppView.swift */; };
E18E0204288749200022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.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 */; }; E18E0208288749200022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; };
E18E021C2887492B0022598C /* 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 */; }; E18E021E2887492B0022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; };
E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; };
E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; };
@ -1275,6 +1278,8 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; }; 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
4E00DCB32D7F7D0300DC3CBB /* VideoRangeType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoRangeType.swift; sourceTree = "<group>"; };
4E00DCB62D7F93ED00DC3CBB /* AttributeBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeBadge.swift; sourceTree = "<group>"; };
4E01446B2D0292E000193038 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = "<group>"; }; 4E01446B2D0292E000193038 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = "<group>"; };
4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = "<group>"; }; 4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = "<group>"; };
4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = "<group>"; }; 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = "<group>"; };
@ -1930,7 +1935,6 @@
E18E01D9288747230022598C /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = "<group>"; }; E18E01D9288747230022598C /* ActionButtonHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonHStack.swift; sourceTree = "<group>"; };
E18E01F3288747580022598C /* AboutAppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = "<group>"; }; E18E01F3288747580022598C /* AboutAppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = "<group>"; };
E18E01FF288749200022598C /* RowDivider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowDivider.swift; sourceTree = "<group>"; }; E18E01FF288749200022598C /* RowDivider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowDivider.swift; sourceTree = "<group>"; };
E18E0202288749200022598C /* AttributeStyleModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeStyleModifier.swift; sourceTree = "<group>"; };
E18E0203288749200022598C /* BlurView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = "<group>"; }; E18E0203288749200022598C /* BlurView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = "<group>"; };
E19070482C84F2BB0004600E /* ButtonStyle-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ButtonStyle-iOS.swift"; sourceTree = "<group>"; }; E19070482C84F2BB0004600E /* ButtonStyle-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ButtonStyle-iOS.swift"; sourceTree = "<group>"; };
E190704B2C858CEB0004600E /* VideoPlayerType+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Shared.swift"; sourceTree = "<group>"; }; E190704B2C858CEB0004600E /* VideoPlayerType+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Shared.swift"; sourceTree = "<group>"; };
@ -2194,6 +2198,7 @@
E15210582946DF1B00375CC2 /* PulseUI in Frameworks */, E15210582946DF1B00375CC2 /* PulseUI in Frameworks */,
E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */, E1153DA42BBA614F00424D36 /* CollectionVGrid in Frameworks */,
62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */, 62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */,
4E9DDBEF2D81EB9E0001C562 /* WrappingHStack in Frameworks */,
62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */, 62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */,
E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */, E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */,
62666E0627E5017A00EC0ECD /* CoreVideo.framework in Frameworks */, 62666E0627E5017A00EC0ECD /* CoreVideo.framework in Frameworks */,
@ -3926,6 +3931,7 @@
E17AC9692954D00E003D2BC2 /* URLResponse.swift */, E17AC9692954D00E003D2BC2 /* URLResponse.swift */,
E19D41AF2BF2B7540082B8B2 /* URLSessionConfiguration.swift */, E19D41AF2BF2B7540082B8B2 /* URLSessionConfiguration.swift */,
E1A1528128FD126C00600579 /* VerticalAlignment.swift */, E1A1528128FD126C00600579 /* VerticalAlignment.swift */,
4E00DCB32D7F7D0300DC3CBB /* VideoRangeType.swift */,
E11895A22893409D0042947B /* ViewExtensions */, E11895A22893409D0042947B /* ViewExtensions */,
); );
path = Extensions; path = Extensions;
@ -4654,7 +4660,6 @@
E170D101294CE4C10017224C /* Modifiers */ = { E170D101294CE4C10017224C /* Modifiers */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E18E0202288749200022598C /* AttributeStyleModifier.swift */,
E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */, E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */,
E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */, E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */,
4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */, 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */,
@ -5057,6 +5062,7 @@
children = ( children = (
E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */, E102314C2BCF8A7E009D71FC /* AlternateLayoutView.swift */,
E104DC952B9E7E29008F506D /* AssertionFailureView.swift */, E104DC952B9E7E29008F506D /* AssertionFailureView.swift */,
4E00DCB62D7F93ED00DC3CBB /* AttributeBadge.swift */,
E18E0203288749200022598C /* BlurView.swift */, E18E0203288749200022598C /* BlurView.swift */,
E145EB212BDCCA43003BF6F3 /* BulletedList.swift */, E145EB212BDCCA43003BF6F3 /* BulletedList.swift */,
4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */, 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */,
@ -5533,6 +5539,7 @@
E176EBE82D050925009F4CF1 /* CollectionVGrid */, E176EBE82D050925009F4CF1 /* CollectionVGrid */,
E1A09F712D05933D00835265 /* CollectionVGrid */, E1A09F712D05933D00835265 /* CollectionVGrid */,
E1A09F742D05935100835265 /* CollectionHStack */, E1A09F742D05935100835265 /* CollectionHStack */,
4E9DDBEE2D81EB9E0001C562 /* WrappingHStack */,
); );
productName = JellyfinPlayer; productName = JellyfinPlayer;
productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */;
@ -5636,6 +5643,7 @@
E176EBDC2D050067009F4CF1 /* XCRemoteSwiftPackageReference "swift-identified-collections" */, E176EBDC2D050067009F4CF1 /* XCRemoteSwiftPackageReference "swift-identified-collections" */,
E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */, E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */,
E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */, E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */,
4E9DDBED2D81E9810001C562 /* XCRemoteSwiftPackageReference "WrappingHStack" */,
); );
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -6065,6 +6073,7 @@
4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */, 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */,
E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */, E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */,
E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */, E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */,
4E00DCB72D7F93F000DC3CBB /* AttributeBadge.swift in Sources */,
E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */, E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */,
E1CAF65E2BA345830087D991 /* MediaType.swift in Sources */, E1CAF65E2BA345830087D991 /* MediaType.swift in Sources */,
E1F5CF062CB09EA000607465 /* CurrentDate.swift in Sources */, E1F5CF062CB09EA000607465 /* CurrentDate.swift in Sources */,
@ -6098,6 +6107,7 @@
E1575E69293E77B5001665B1 /* ItemSortBy.swift in Sources */, E1575E69293E77B5001665B1 /* ItemSortBy.swift in Sources */,
E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */, E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */,
4E656C312D0798AA00F993F3 /* ParentalRating.swift in Sources */, 4E656C312D0798AA00F993F3 /* ParentalRating.swift in Sources */,
4E00DCB52D7F7D0900DC3CBB /* VideoRangeType.swift in Sources */,
E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */, E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */,
E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */, E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */,
E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
@ -6286,7 +6296,6 @@
E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */, E1E6C44929AECEE70064123F /* AutoPlayActionButton.swift in Sources */,
E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */, E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */,
E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */,
E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */,
E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */, E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */,
4EB132F02D2CF6D600B5A8E5 /* ImageType.swift in Sources */, 4EB132F02D2CF6D600B5A8E5 /* ImageType.swift in Sources */,
E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
@ -6397,6 +6406,7 @@
E1E1644128BB301900323B0A /* Array.swift in Sources */, E1E1644128BB301900323B0A /* Array.swift in Sources */,
E18CE0AF28A222240092E7F1 /* PublicUserRow.swift in Sources */, E18CE0AF28A222240092E7F1 /* PublicUserRow.swift in Sources */,
E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */, E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */,
4E00DCB82D7F94CA00DC3CBB /* AttributeBadge.swift in Sources */,
E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */, E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */,
E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */,
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */,
@ -6606,7 +6616,6 @@
E19D41A72BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift in Sources */, E19D41A72BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift in Sources */,
E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */, E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */,
E15D63EF2BD6DFC200AA665D /* SystemImageable.swift in Sources */, E15D63EF2BD6DFC200AA665D /* SystemImageable.swift in Sources */,
E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */,
E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */, E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */,
4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */, 4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */,
4E45939E2D04E20000E277E1 /* ItemImagesViewModel.swift in Sources */, 4E45939E2D04E20000E277E1 /* ItemImagesViewModel.swift in Sources */,
@ -6835,6 +6844,7 @@
E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */, E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */,
E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */, E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */,
4E2AC4C22C6C491200DD600D /* AudoCodec.swift in Sources */, 4E2AC4C22C6C491200DD600D /* AudoCodec.swift in Sources */,
4E00DCB42D7F7D0900DC3CBB /* VideoRangeType.swift in Sources */,
4E49DED52CE54D9D00352DCD /* LoginFailurePolicy.swift in Sources */, 4E49DED52CE54D9D00352DCD /* LoginFailurePolicy.swift in Sources */,
E10B1EC72BD9AF6100A92EAF /* V2ServerModel.swift in Sources */, E10B1EC72BD9AF6100A92EAF /* V2ServerModel.swift in Sources */,
E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */,
@ -7606,6 +7616,14 @@
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference 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" */ = { 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/siteline/SwiftUI-Introspect"; repositoryURL = "https://github.com/siteline/SwiftUI-Introspect";
@ -7793,6 +7811,11 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
4E9DDBEE2D81EB9E0001C562 /* WrappingHStack */ = {
isa = XCSwiftPackageProductDependency;
package = 4E9DDBED2D81E9810001C562 /* XCRemoteSwiftPackageReference "WrappingHStack" */;
productName = WrappingHStack;
};
6220D0C826D63F3700B8E046 /* Stinsen */ = { 6220D0C826D63F3700B8E046 /* Stinsen */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */;

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "b7189175c8066640649da818750e83deee8ef2f766db25c34025f23d451b301d", "originHash" : "06a5dcccf9b916d25a75602bcb345218714cc642e54ef793a56ac6adc5439df8",
"pins" : [ "pins" : [
{ {
"identity" : "blurhashkit", "identity" : "blurhashkit",
@ -252,6 +252,15 @@
"branch" : "main", "branch" : "main",
"revision" : "50d4f6ec05a2d8333952def0d8e45019a4207132" "revision" : "50d4f6ec05a2d8333952def0d8e45019a4207132"
} }
},
{
"identity" : "wrappinghstack",
"kind" : "remoteSourceControl",
"location" : "https://github.com/dkk/WrappingHStack",
"state" : {
"revision" : "425d9488ba55f58f0b34498c64c054c77fc2a44b",
"version" : "2.2.11"
}
} }
], ],
"version" : 3 "version" : 3

View File

@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import WrappingHStack
extension ItemView { extension ItemView {
struct AttributesHStack: View { struct AttributesHStack: View {
@ -16,74 +17,146 @@ extension ItemView {
@StoredValue(.User.itemViewAttributes) @StoredValue(.User.itemViewAttributes)
private var itemViewAttributes private var itemViewAttributes
// MARK: - Body
var body: some View { var body: some View {
HStack { let badges = computeBadges()
ForEach(itemViewAttributes, id: \.self) { attribute in if !badges.isEmpty {
getAttribute(attribute) 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 // MARK: - Compute Badges
func getAttribute(_ attribute: ItemViewAttribute) -> some View {
switch attribute { private func computeBadges() -> [AnyView] {
case .ratingCritics: var badges: [AnyView] = []
if let criticRating = viewModel.item.criticRating { var processedGroups = Set<ItemViewAttribute>()
HStack(spacing: 2) {
Group { for attribute in itemViewAttributes {
if criticRating >= 60 {
Image(.tomatoFresh) if processedGroups.contains(attribute) { continue }
.symbolRenderingMode(.hierarchical) processedGroups.insert(attribute)
} else {
Image(.tomatoRotten) 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 .audioChannels:
} if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams {
case .ratingCommunity: if mediaStreams.has51AudioChannelLayout {
if let communityRating = viewModel.item.communityRating { let badge = AnyView(
HStack(spacing: 2) { AttributeBadge(
Image(systemName: "star.fill") style: .fill,
.font(.caption2) title: "5.1"
)
Text("\(communityRating, specifier: "%.1f")") )
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
} }
} }
} }