diff --git a/Shared/Components/SeparatorVStack.swift b/Shared/Components/SeparatorVStack.swift index 6d0d1fef..44721f99 100644 --- a/Shared/Components/SeparatorVStack.swift +++ b/Shared/Components/SeparatorVStack.swift @@ -10,17 +10,18 @@ import SwiftUI // https://movingparts.io/variadic-views-in-swiftui -/// An `HStack` that inserts an optional `separator` between views. +/// A `VStack` that inserts an optional `separator` between views. /// /// - Note: Default spacing is removed. The separator view is responsible /// for spacing. struct SeparatorVStack: View { - private var content: () -> Content - private var separator: () -> Separator + private let alignment: HorizontalAlignment + private let content: () -> Content + private let separator: () -> Separator var body: some View { - _VariadicView.Tree(SeparatorVStackLayout(separator: separator)) { + _VariadicView.Tree(SeparatorVStackLayout(alignment: alignment, separator: separator)) { content() } } @@ -29,10 +30,12 @@ struct SeparatorVStack: View { extension SeparatorVStack { init( + alignment: HorizontalAlignment = .center, @ViewBuilder separator: @escaping () -> Separator, @ViewBuilder content: @escaping () -> Content ) { self.init( + alignment: alignment, content: content, separator: separator ) @@ -43,14 +46,15 @@ extension SeparatorVStack { struct SeparatorVStackLayout: _VariadicView_UnaryViewRoot { - var separator: () -> Separator + let alignment: HorizontalAlignment + let separator: () -> Separator @ViewBuilder func body(children: _VariadicView.Children) -> some View { let last = children.last?.id - localHStack { + VStack(alignment: alignment, spacing: 0) { ForEach(children) { child in child @@ -60,12 +64,5 @@ extension SeparatorVStack { } } } - - @ViewBuilder - private func localHStack(@ViewBuilder content: @escaping () -> some View) -> some View { - VStack(spacing: 0) { - content() - } - } } } diff --git a/Shared/Coordinators/AdminDashboardCoordinator.swift b/Shared/Coordinators/AdminDashboardCoordinator.swift index c68c7480..4222421d 100644 --- a/Shared/Coordinators/AdminDashboardCoordinator.swift +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -60,6 +60,8 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { @Route(.modal) var userPermissions = makeUserPermissions @Route(.modal) + var userParentalRatings = makeUserParentalRatings + @Route(.modal) var resetUserPassword = makeResetUserPassword @Route(.modal) var addServerUser = makeAddServerUser @@ -160,6 +162,12 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { } } + func makeUserParentalRatings(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ServerUserParentalRatingView(viewModel: viewModel) + } + } + func makeResetUserPassword(userID: String) -> NavigationViewCoordinator { NavigationViewCoordinator { ResetUserPasswordView(userID: userID, requiresCurrentPassword: false) diff --git a/Shared/Extensions/JellyfinAPI/ParentalRating.swift b/Shared/Extensions/JellyfinAPI/ParentalRating.swift new file mode 100644 index 00000000..dc57c712 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/ParentalRating.swift @@ -0,0 +1,36 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +extension UnratedItem: Displayable { + + var displayTitle: String { + switch self { + case .movie: + L10n.movies + case .trailer: + L10n.trailers + case .series: + L10n.tvShows + case .music: + L10n.music + case .book: + L10n.books + case .liveTvChannel: + L10n.liveTVChannels + case .liveTvProgram: + L10n.liveTVPrograms + case .channelContent: + L10n.channels + case .other: + L10n.other + } + } +} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index d34db0b1..73bf6006 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -22,6 +22,10 @@ internal enum L10n { internal static let access = L10n.tr("Localizable", "access", fallback: "Access") /// Accessibility internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility") + /// Access schedule + internal static let accessSchedule = L10n.tr("Localizable", "accessSchedule", fallback: "Access schedule") + /// Create an access schedule to limit access to certain hours. + internal static let accessScheduleDescription = L10n.tr("Localizable", "accessScheduleDescription", fallback: "Create an access schedule to limit access to certain hours.") /// Active internal static let active = L10n.tr("Localizable", "active", fallback: "Active") /// Active Devices @@ -50,6 +54,10 @@ internal enum L10n { internal static let administrator = L10n.tr("Localizable", "administrator", fallback: "Administrator") /// Advanced internal static let advanced = L10n.tr("Localizable", "advanced", fallback: "Advanced") + /// Age %@ + internal static func agesGroup(_ p1: Any) -> String { + return L10n.tr("Localizable", "agesGroup", String(describing: p1), fallback: "Age %@") + } /// Aired internal static let aired = L10n.tr("Localizable", "aired", fallback: "Aired") /// Air Time @@ -60,6 +68,8 @@ internal enum L10n { } /// Album Artist internal static let albumArtist = L10n.tr("Localizable", "albumArtist", fallback: "Album Artist") + /// All Audiences + internal static let allAudiences = L10n.tr("Localizable", "allAudiences", fallback: "All Audiences") /// View all past and present devices that have connected. internal static let allDevicesDescription = L10n.tr("Localizable", "allDevicesDescription", fallback: "View all past and present devices that have connected.") /// All Genres @@ -68,6 +78,10 @@ internal enum L10n { internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") /// Allow collection management internal static let allowCollectionManagement = L10n.tr("Localizable", "allowCollectionManagement", fallback: "Allow collection management") + /// Allowed tags + internal static let allowedTags = L10n.tr("Localizable", "allowedTags", fallback: "Allowed tags") + /// Only show media to this user with at least one of the specified tags. + internal static let allowedTagsDescription = L10n.tr("Localizable", "allowedTagsDescription", fallback: "Only show media to this user with at least one of the specified tags.") /// Allow media item deletion internal static let allowItemDeletion = L10n.tr("Localizable", "allowItemDeletion", fallback: "Allow media item deletion") /// Allow media item editing @@ -198,8 +212,18 @@ internal enum L10n { internal static let bitrateTestDisclaimer = L10n.tr("Localizable", "bitrateTestDisclaimer", fallback: "Longer tests are more accurate but may result in a delayed playback.") /// bps internal static let bitsPerSecond = L10n.tr("Localizable", "bitsPerSecond", fallback: "bps") + /// Blocked tags + internal static let blockedTags = L10n.tr("Localizable", "blockedTags", fallback: "Blocked tags") + /// Hide media with at least one of the specified tags. + internal static let blockedTagsDescription = L10n.tr("Localizable", "blockedTagsDescription", fallback: "Hide media with at least one of the specified tags.") + /// Block unrated items + internal static let blockUnratedItems = L10n.tr("Localizable", "blockUnratedItems", fallback: "Block unrated items") + /// Block items from this user with no or unrecognized rating information. + internal static let blockUnratedItemsDescription = L10n.tr("Localizable", "blockUnratedItemsDescription", fallback: "Block items from this user with no or unrecognized rating information.") /// Blue internal static let blue = L10n.tr("Localizable", "blue", fallback: "Blue") + /// Books + internal static let books = L10n.tr("Localizable", "books", fallback: "Books") /// Bugs and Features internal static let bugsAndFeatures = L10n.tr("Localizable", "bugsAndFeatures", fallback: "Bugs and Features") /// Buttons @@ -736,6 +760,10 @@ internal enum L10n { internal static let liveTV = L10n.tr("Localizable", "liveTV", fallback: "Live TV") /// Live TV access internal static let liveTvAccess = L10n.tr("Localizable", "liveTvAccess", fallback: "Live TV access") + /// Live TV Channels + internal static let liveTVChannels = L10n.tr("Localizable", "liveTVChannels", fallback: "Live TV Channels") + /// Live TV Programs + internal static let liveTVPrograms = L10n.tr("Localizable", "liveTVPrograms", fallback: "Live TV Programs") /// Live TV recording management internal static let liveTvRecordingManagement = L10n.tr("Localizable", "liveTvRecordingManagement", fallback: "Live TV recording management") /// Loading @@ -782,6 +810,10 @@ internal enum L10n { internal static let maximumSessions = L10n.tr("Localizable", "maximumSessions", fallback: "Maximum sessions") /// Maximum sessions policy internal static let maximumSessionsPolicy = L10n.tr("Localizable", "maximumSessionsPolicy", fallback: "Maximum sessions policy") + /// Maximum parental rating + internal static let maxParentalRating = L10n.tr("Localizable", "maxParentalRating", fallback: "Maximum parental rating") + /// Content with a higher rating will be hidden from this user. + internal static let maxParentalRatingDescription = L10n.tr("Localizable", "maxParentalRatingDescription", fallback: "Content with a higher rating will be hidden from this user.") /// This setting may result in media failing to start playback. internal static let mayResultInPlaybackFailure = L10n.tr("Localizable", "mayResultInPlaybackFailure", fallback: "This setting may result in media failing to start playback.") /// Media @@ -818,6 +850,8 @@ internal enum L10n { internal static func multipleUsers(_ p1: Int) -> String { return L10n.tr("Localizable", "multipleUsers", p1, fallback: "%d users") } + /// Music + internal static let music = L10n.tr("Localizable", "music", fallback: "Music") /// MVC internal static let mvc = L10n.tr("Localizable", "mvc", fallback: "MVC") /// Name @@ -926,6 +960,8 @@ internal enum L10n { internal static func pageOfWithNumbers(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "pageOfWithNumbers", String(describing: p1), String(describing: p2), fallback: "Page %1$@ of %2$@") } + /// Parental controls + internal static let parentalControls = L10n.tr("Localizable", "parentalControls", fallback: "Parental controls") /// Parental Rating internal static let parentalRating = L10n.tr("Localizable", "parentalRating", fallback: "Parental Rating") /// Password @@ -1394,6 +1430,8 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "title", fallback: "Title") /// Too Many Redirects internal static let tooManyRedirects = L10n.tr("Localizable", "tooManyRedirects", fallback: "Too Many Redirects") + /// Trailers + internal static let trailers = L10n.tr("Localizable", "trailers", fallback: "Trailers") /// Trailing Value internal static let trailingValue = L10n.tr("Localizable", "trailingValue", fallback: "Trailing Value") /// Transcode diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 72d9df83..d191e033 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */; }; 4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; + 4E2470082D078DD7009139D8 /* ServerUserParentalRatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2470062D078DD7009139D8 /* ServerUserParentalRatingView.swift */; }; 4E24ECFB2D076F6200A473A9 /* ListRowCheckbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24ECFA2D076F2B00A473A9 /* ListRowCheckbox.swift */; }; 4E24ECFC2D076F6200A473A9 /* ListRowCheckbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24ECFA2D076F2B00A473A9 /* ListRowCheckbox.swift */; }; 4E2AC4BE2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; }; @@ -100,6 +101,8 @@ 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; 4E63B9FA2C8A5BEF00C25378 /* AdminDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */; }; 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; }; + 4E656C302D0798AA00F993F3 /* ParentalRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */; }; + 4E656C312D0798AA00F993F3 /* ParentalRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */; }; 4E6619FC2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */; }; 4E6619FD2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */; }; 4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A002CEFE39900025C99 /* EditMetadataView.swift */; }; @@ -1181,6 +1184,7 @@ 4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskRow.swift; sourceTree = ""; }; 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = ""; }; 4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = ""; }; + 4E2470062D078DD7009139D8 /* ServerUserParentalRatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserParentalRatingView.swift; sourceTree = ""; }; 4E24ECFA2D076F2B00A473A9 /* ListRowCheckbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowCheckbox.swift; sourceTree = ""; }; 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileAction.swift; sourceTree = ""; }; 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudoCodec.swift; sourceTree = ""; }; @@ -1228,6 +1232,7 @@ 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = ""; }; 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = ""; }; + 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentalRating.swift; sourceTree = ""; }; 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorViewModel.swift; sourceTree = ""; }; 4E661A002CEFE39900025C99 /* EditMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataView.swift; sourceTree = ""; }; 4E661A042CEFE46300025C99 /* DateSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateSection.swift; sourceTree = ""; }; @@ -2142,6 +2147,14 @@ path = Components; sourceTree = ""; }; + 4E2470072D078DD7009139D8 /* ServerUserParentalRatingView */ = { + isa = PBXGroup; + children = ( + 4E2470062D078DD7009139D8 /* ServerUserParentalRatingView.swift */, + ); + path = ServerUserParentalRatingView; + sourceTree = ""; + }; 4E2AC4C02C6C48EB00DD600D /* MediaComponents */ = { isa = PBXGroup; children = ( @@ -2299,6 +2312,7 @@ 4E35CE622CBED3FF00DBD886 /* ServerLogsView */, 4E182C9A2C94991800FBEFD5 /* ServerTasksView */, 4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */, + 4E2470072D078DD7009139D8 /* ServerUserParentalRatingView */, 4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */, 4E537A8C2D04410E00659A1A /* ServerUserLiveTVAccessView */, 4EF3D80A2CF7D6670081AD20 /* ServerUserAccessView */, @@ -4234,6 +4248,7 @@ E122A9122788EAAD0060FA63 /* MediaStream.swift */, 4E661A2D2CEFE77700025C99 /* MetadataField.swift */, E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */, + 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */, 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */, E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, @@ -5162,6 +5177,7 @@ E1356E0429A731EB00382563 /* SeparatorHStack.swift in Sources */, E1575E69293E77B5001665B1 /* ItemSortBy.swift in Sources */, E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */, + 4E656C312D0798AA00F993F3 /* ParentalRating.swift in Sources */, E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */, E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, @@ -5384,6 +5400,7 @@ 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */, 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */, + 4E2470082D078DD7009139D8 /* ServerUserParentalRatingView.swift in Sources */, E1A1528828FD229500600579 /* ChevronButton.swift in Sources */, E1CB75732C80E71800217C76 /* DirectPlayProfile.swift in Sources */, E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */, @@ -5546,6 +5563,7 @@ 4E2AC4C52C6C492700DD600D /* MediaContainer.swift in Sources */, 4E2AC4CB2C6C494E00DD600D /* VideoCodec.swift in Sources */, E1EA09692BED78BB004CDE76 /* UserAccessPolicy.swift in Sources */, + 4E656C302D0798AA00F993F3 /* ParentalRating.swift in Sources */, E18E0204288749200022598C /* RowDivider.swift in Sources */, E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, E1CB75752C80EAFA00217C76 /* ArrayBuilder.swift in Sources */, diff --git a/Swiftfin/Components/LearnMoreButton.swift b/Swiftfin/Components/LearnMoreButton.swift index 37097847..c969b3d8 100644 --- a/Swiftfin/Components/LearnMoreButton.swift +++ b/Swiftfin/Components/LearnMoreButton.swift @@ -42,7 +42,9 @@ struct LearnMoreButton: View { private var learnMoreView: some View { NavigationView { ScrollView { - VStack(alignment: .leading, spacing: 16) { + SeparatorVStack(alignment: .leading) { + Divider() + } content: { ForEach(items) { content in VStack(alignment: .leading, spacing: 8) { Text(content.title) @@ -53,10 +55,10 @@ struct LearnMoreButton: View { .font(.subheadline) .foregroundStyle(.secondary) } - Divider() + .padding(.vertical, 16) } } - .edgePadding() + .edgePadding(.horizontal) } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) @@ -64,5 +66,6 @@ struct LearnMoreButton: View { isPresented = false } } + .foregroundStyle(Color.primary, Color.secondary) } } diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index b2471eff..587fa417 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -11,7 +11,6 @@ import JellyfinAPI import SwiftUI struct ServerUserDetailsView: View { - @EnvironmentObject private var router: AdminDashboardCoordinator.Router @@ -68,31 +67,27 @@ struct ServerUserDetailsView: View { } } - /* Section("Parental controls") { - // TODO: Allow items SDK 10.10 - allowedTags - ChevronButton("Allow items") - .onSelect { - router.route(to: \.userAllowedTags, viewModel) - } - - // TODO: Block items - blockedTags - ChevronButton("Block items") - .onSelect { - router.route(to: \.userBlockedTags, viewModel) - } - - // TODO: Access Schedules - accessSchedules - ChevronButton("Access schedule") + Section(L10n.parentalControls) { + // TODO: Access Schedules - accessSchedules + /* ChevronButton("Access schedule") .onSelect { router.route(to: \.userAccessSchedules, viewModel) } - - // TODO: Parental Rating - maxParentalRating, blockUnratedItems - ChevronButton("Parental rating") - .onSelect { - router.route(to: \.userParentalRating, viewModel) - } - } */ + // TODO: Allow items SDK 10.10 - allowedTags + ChevronButton("Allow items") + .onSelect { + router.route(to: \.userAllowedTags, viewModel) + } + // TODO: Block items - blockedTags + ChevronButton("Block items") + .onSelect { + router.route(to: \.userBlockedTags, viewModel) + }*/ + ChevronButton(L10n.ratings) + .onSelect { + router.route(to: \.userParentalRatings, viewModel) + } + } } .navigationTitle(L10n.user) .onAppear { diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserParentalRatingView/ServerUserParentalRatingView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserParentalRatingView/ServerUserParentalRatingView.swift new file mode 100644 index 00000000..d1518b63 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserParentalRatingView/ServerUserParentalRatingView.swift @@ -0,0 +1,193 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +struct ServerUserParentalRatingView: View { + + // MARK: - Observed, State, & Environment Objects + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + @StateObject + private var viewModel: ServerUserAdminViewModel + @ObservedObject + private var parentalRatingsViewModel = ParentalRatingsViewModel() + + // MARK: - Policy Variable + + @State + private var tempPolicy: UserPolicy + + // MARK: - Error State + + @State + private var error: Error? + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + self.tempPolicy = viewModel.user.policy ?? UserPolicy() + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.parentalRating) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.updating) { + ProgressView() + } + Button(L10n.save) { + if tempPolicy != viewModel.user.policy { + viewModel.send(.updatePolicy(tempPolicy)) + } + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.user.policy == tempPolicy) + } + .onFirstAppear { + parentalRatingsViewModel.send(.refresh) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + case .updated: + UIDevice.feedback(.success) + router.dismissCoordinator() + } + } + .errorMessage($error) + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + List { + maxParentalRatingsView + blockUnratedItemsView + } + } + + // MARK: - Maximum Parental Ratings View + + @ViewBuilder + var maxParentalRatingsView: some View { + Section { + Picker(L10n.parentalRating, selection: $tempPolicy.maxParentalRating) { + ForEach(parentalRatingGroups, id: \.value) { rating in + Text(rating.name ?? L10n.unknown) + .tag(rating.value) + } + } + } header: { + Text(L10n.maxParentalRating) + } footer: { + VStack(alignment: .leading) { + Text(L10n.maxParentalRatingDescription) + + LearnMoreButton(L10n.parentalRating) { + parentalRatingLearnMore + } + } + } + } + + // MARK: - Block Unrated Items View + + @ViewBuilder + var blockUnratedItemsView: some View { + Section { + ForEach(UnratedItem.allCases.sorted(using: \.displayTitle), id: \.self) { item in + Toggle( + item.displayTitle, + isOn: $tempPolicy.blockUnratedItems + .coalesce([]) + .contains(item) + ) + } + } header: { + Text(L10n.blockUnratedItems) + } footer: { + Text(L10n.blockUnratedItemsDescription) + } + } + + // MARK: - Parental Rating Groups + + private var parentalRatingGroups: [ParentalRating] { + let groups = Dictionary( + grouping: parentalRatingsViewModel.parentalRatings + ) { + $0.value ?? 0 + } + + var groupedRatings = groups.map { key, group in + if key < 100 { + if key == 0 { + return ParentalRating(name: L10n.allAudiences, value: key) + } else { + return ParentalRating(name: L10n.agesGroup(key), value: key) + } + } else { + // Concatenate all 100+ ratings at the same value with '/' but as of 10.10 there should be none. + let name = group + .compactMap(\.name) + .sorted() + .joined(separator: " / ") + + return ParentalRating(name: name, value: key) + } + } + .sorted(using: \.value) + + let unrated = ParentalRating(name: L10n.none, value: nil) + groupedRatings.insert(unrated, at: 0) + + return groupedRatings + } + + // MARK: - Parental Rating Learn More + + private var parentalRatingLearnMore: [TextPair] { + let groups = Dictionary( + grouping: parentalRatingsViewModel.parentalRatings + ) { + $0.value ?? 0 + } + .sorted(using: \.key) + + let groupedRatings = groups.compactMap { key, group in + let matchingRating = parentalRatingGroups.first { $0.value == key } + + let name = group + .compactMap(\.name) + .sorted() + .joined(separator: "\n") + + return TextPair( + title: matchingRating?.name ?? L10n.none, + subtitle: name + ) + } + + return groupedRatings + } +} diff --git a/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift b/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift index 332a1464..1c003daf 100644 --- a/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift +++ b/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift @@ -43,7 +43,6 @@ struct PlaybackQualitySettingsView: View { subtitle: L10n.bitrateMaxDescription(PlaybackBitrate.max.rawValue.formatted(.bitRate)) ) } - .foregroundStyle(.foreground, .primary) } } .animation(.none, value: appMaximumBitrate) @@ -99,7 +98,6 @@ struct PlaybackQualitySettingsView: View { subtitle: L10n.customDescription ) } - .foregroundStyle(.foreground, .primary) } } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index b835412e..6d2f2b11 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -2049,6 +2049,70 @@ // Represents a translator "translator" = "Translator"; +// Parental controls - Section Title +// Parental controls section & view titles +"parentalControls" = "Parental controls"; + +// Block unrated items - Section Title +// Parental ratings block unrated items section +"blockUnratedItems" = "Block unrated items"; + +// Block unrated items - Footer +// Parental ratings block unrated items description +"blockUnratedItemsDescription" = "Block items from this user with no or unrecognized rating information."; + +// Maximum parental rating - Section Title +// Parental ratings maximum parental rating section +"maxParentalRating" = "Maximum parental rating"; + +// Maximum parental rating - Footer +// Parental ratings maximum parental rating description +"maxParentalRatingDescription" = "Content with a higher rating will be hidden from this user."; + +// Allowed tags - View Title +// Parental ratings section for allowed tags +"allowedTags" = "Allowed tags"; + +// Allowed tags - Footer +// Parental ratings description for allowed tags +"allowedTagsDescription" = "Only show media to this user with at least one of the specified tags."; + +// Blocked tags - View Title +// Parental ratings section for blocked tags +"blockedTags" = "Blocked tags"; + +// Blocked tags - Footer +// Parental ratings description for blocked tags +"blockedTagsDescription" = "Hide media with at least one of the specified tags."; + +// Access Schedule - View Title +// Parental ratings section for blocked titles +"accessSchedule" = "Access schedule"; + +// Access Schedule - Footer +// Parental ratings section for allowed titles +"accessScheduleDescription" = "Create an access schedule to limit access to certain hours."; + +// Trailers - Section Title +// Title for content classified as trailers +"trailers" = "Trailers"; + +// Music - Section Title +// Title for content classified as music +"music" = "Music"; + +// Books - Section Title +// Title for content classified as books +"books" = "Books"; + +// Live TV Channels - Section Title +// Title for content classified as live TV channels +"liveTVChannels" = "Live TV Channels"; + +// Live TV Programs - Section Title +// Title for content classified as live TV programs +"liveTVPrograms" = "Live TV Programs"; + // Loading User Failed - Error Message // Displayed when loading user data fails "loadingUserFailed" = "Loading user failed"; @@ -2180,3 +2244,11 @@ // Server Already Connected - Error Message // Indicates that the specified server is already connected "serverAlreadyConnected" = "%@ is already connected."; + +// All Audiences - Group Name +// Label for content suitable for all audiences +"allAudiences" = "All Audiences"; + +// Ages Group - Group Name +// Label for content suitable for a specific age group +"agesGroup" = "Age %@";