From 174487a220b63ee83432fcfe21a1347eda0ac4a6 Mon Sep 17 00:00:00 2001 From: Joe Kribs Date: Mon, 9 Dec 2024 16:33:10 -0700 Subject: [PATCH] [iOS] Admin Dashboard - User Device & TV Access (#1342) --- Shared/Components/ListRowCheckbox.swift | 48 +++++++ Shared/Components/SeparatorVStack.swift | 71 ++++++++++ .../AdminDashboardCoordinator.swift | 18 ++- Shared/Strings/Strings.swift | 8 +- Swiftfin.xcodeproj/project.pbxproj | 40 +++++- Swiftfin/Components/ListTitleSection.swift | 117 +++++++++++----- .../DevicesView/Components/DeviceRow.swift | 59 ++++---- .../DevicesView/DevicesView.swift | 3 +- .../ServerUserAccessView.swift | 2 +- .../ServerUserDetailsView.swift | 57 ++++++-- .../ServerUserDeviceAccessView.swift | 130 ++++++++++++++++++ .../ServerUserLiveTVAccessView.swift | 106 ++++++++++++++ .../Components/ServerUsersRow.swift | 24 +--- .../ServerUsersView/ServerUsersView.swift | 3 +- .../Components/EditItemElementRow.swift | 6 +- .../EditItemElementView.swift | 1 + .../Components/Sections/SeriesSection.swift | 2 +- .../SelectUserView/Components/UserRow.swift | 20 +-- Translations/en.lproj/Localizable.strings | 16 ++- 19 files changed, 596 insertions(+), 135 deletions(-) create mode 100644 Shared/Components/ListRowCheckbox.swift create mode 100644 Shared/Components/SeparatorVStack.swift create mode 100644 Swiftfin/Views/AdminDashboardView/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift create mode 100644 Swiftfin/Views/AdminDashboardView/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift diff --git a/Shared/Components/ListRowCheckbox.swift b/Shared/Components/ListRowCheckbox.swift new file mode 100644 index 00000000..01b01a70 --- /dev/null +++ b/Shared/Components/ListRowCheckbox.swift @@ -0,0 +1,48 @@ +// +// 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 SwiftUI + +struct ListRowCheckbox: View { + + @Default(.accentColor) + private var accentColor + + // MARK: - Environment Variables + + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + // MARK: - Body + + @ViewBuilder + var body: some View { + if isEditing, isSelected { + Image(systemName: "checkmark.circle.fill") + .resizable() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + + } else if isEditing { + Image(systemName: "circle") + .resizable() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(.secondary) + } + } +} diff --git a/Shared/Components/SeparatorVStack.swift b/Shared/Components/SeparatorVStack.swift new file mode 100644 index 00000000..6d0d1fef --- /dev/null +++ b/Shared/Components/SeparatorVStack.swift @@ -0,0 +1,71 @@ +// +// 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 SwiftUI + +// https://movingparts.io/variadic-views-in-swiftui + +/// An `HStack` 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 + + var body: some View { + _VariadicView.Tree(SeparatorVStackLayout(separator: separator)) { + content() + } + } +} + +extension SeparatorVStack { + + init( + @ViewBuilder separator: @escaping () -> Separator, + @ViewBuilder content: @escaping () -> Content + ) { + self.init( + content: content, + separator: separator + ) + } +} + +extension SeparatorVStack { + + struct SeparatorVStackLayout: _VariadicView_UnaryViewRoot { + + var separator: () -> Separator + + @ViewBuilder + func body(children: _VariadicView.Children) -> some View { + + let last = children.last?.id + + localHStack { + ForEach(children) { child in + child + + if child.id != last { + separator() + } + } + } + } + + @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 11924134..c68c7480 100644 --- a/Shared/Coordinators/AdminDashboardCoordinator.swift +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -52,8 +52,12 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { @Route(.push) var userDetails = makeUserDetails @Route(.modal) + var userDeviceAccess = makeUserDeviceAccess + @Route(.modal) var userMediaAccess = makeUserMediaAccess @Route(.modal) + var userLiveTVAccess = makeUserLiveTVAccess + @Route(.modal) var userPermissions = makeUserPermissions @Route(.modal) var resetUserPassword = makeResetUserPassword @@ -132,9 +136,21 @@ final class AdminDashboardCoordinator: NavigationCoordinatable { } } + func makeUserDeviceAccess(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ServerUserDeviceAccessView(viewModel: viewModel) + } + } + func makeUserMediaAccess(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { NavigationViewCoordinator { - ServerUserAccessView(viewModel: viewModel) + ServerUserMediaAccessView(viewModel: viewModel) + } + } + + func makeUserLiveTVAccess(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ServerUserLiveTVAccessView(viewModel: viewModel) } } diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index bf847175..b182cf92 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -436,6 +436,8 @@ internal enum L10n { internal static let details = L10n.tr("Localizable", "details", fallback: "Details") /// Device internal static let device = L10n.tr("Localizable", "device", fallback: "Device") + /// Device Access + internal static let deviceAccess = L10n.tr("Localizable", "deviceAccess", fallback: "Device Access") /// Device Profile internal static let deviceProfile = L10n.tr("Localizable", "deviceProfile", fallback: "Device Profile") /// Decide which media plays natively or requires server transcoding for compatibility. @@ -484,6 +486,8 @@ internal enum L10n { internal static let editUsers = L10n.tr("Localizable", "editUsers", fallback: "Edit Users") /// Empty Next Up internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: "Empty Next Up") + /// Enable all devices + internal static let enableAllDevices = L10n.tr("Localizable", "enableAllDevices", fallback: "Enable all devices") /// Enable all libraries internal static let enableAllLibraries = L10n.tr("Localizable", "enableAllLibraries", fallback: "Enable all libraries") /// Enabled @@ -1086,8 +1090,6 @@ internal enum L10n { internal static let run = L10n.tr("Localizable", "run", fallback: "Run") /// Running... internal static let running = L10n.tr("Localizable", "running", fallback: "Running...") - /// Run Time - internal static let runTime = L10n.tr("Localizable", "runTime", fallback: "Run Time") /// Runtime internal static let runtime = L10n.tr("Localizable", "runtime", fallback: "Runtime") /// Save @@ -1344,6 +1346,8 @@ internal enum L10n { internal static let tryAgain = L10n.tr("Localizable", "tryAgain", fallback: "Try again") /// TV internal static let tv = L10n.tr("Localizable", "tv", fallback: "TV") + /// TV Access + internal static let tvAccess = L10n.tr("Localizable", "tvAccess", fallback: "TV Access") /// TV Shows internal static let tvShows = L10n.tr("Localizable", "tvShows", fallback: "TV Shows") /// Type diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index fad95049..faf6f718 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ 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 */; }; + 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 */; }; 4E2AC4BF2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; }; 4E2AC4C22C6C491200DD600D /* AudoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */; }; @@ -91,6 +93,8 @@ 4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */; }; 4E5071E42CFCEFD3003FA2AD /* AddItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */; }; 4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */; }; + 4E537A842D03D11200659A1A /* ServerUserDeviceAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */; }; + 4E537A8D2D04410E00659A1A /* ServerUserLiveTVAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */; }; 4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; 4E556AB12D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; @@ -1045,6 +1049,8 @@ E1DD20412BE1EB8C00C0DE51 /* AddUserButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */; }; E1DD55372B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; E1DD55382B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; + E1DD95CC2D07876400335494 /* SeparatorVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD95CB2D07876400335494 /* SeparatorVStack.swift */; }; + E1DD95CD2D07876400335494 /* SeparatorVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD95CB2D07876400335494 /* SeparatorVStack.swift */; }; E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE2B492B97ECB900F6715F /* ErrorView.swift */; }; E1DE64922CC6F0C900E423B6 /* DeviceSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */; }; E1DE84142B9531C1008CCE21 /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */; }; @@ -1171,6 +1177,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 = ""; }; + 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 = ""; }; 4E2AC4C42C6C492700DD600D /* MediaContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainer.swift; sourceTree = ""; }; @@ -1211,6 +1218,8 @@ 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenreEditorViewModel.swift; sourceTree = ""; }; 4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddItemElementView.swift; sourceTree = ""; }; 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; + 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserDeviceAccessView.swift; sourceTree = ""; }; + 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserLiveTVAccessView.swift; sourceTree = ""; }; 4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = ""; }; 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 = ""; }; @@ -1863,6 +1872,7 @@ E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteIndicator.swift; sourceTree = ""; }; E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddUserButton.swift; sourceTree = ""; }; E1DD55362B6EE533007501C0 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; + E1DD95CB2D07876400335494 /* SeparatorVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorVStack.swift; sourceTree = ""; }; E1DE2B492B97ECB900F6715F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSection.swift; sourceTree = ""; }; E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; @@ -2148,8 +2158,8 @@ 4E31EFA22CFFFB410053DFE7 /* EditItemElementView */ = { isa = PBXGroup; children = ( - 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */, 4E31EFA32CFFFB480053DFE7 /* Components */, + 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */, ); path = EditItemElementView; sourceTree = ""; @@ -2249,6 +2259,22 @@ path = ActionButtons; sourceTree = ""; }; + 4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */ = { + isa = PBXGroup; + children = ( + 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */, + ); + path = ServerUserDeviceAccessView; + sourceTree = ""; + }; + 4E537A8C2D04410E00659A1A /* ServerUserLiveTVAccessView */ = { + isa = PBXGroup; + children = ( + 4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */, + ); + path = ServerUserLiveTVAccessView; + sourceTree = ""; + }; 4E63B9F52C8A5BEF00C25378 /* AdminDashboardView */ = { isa = PBXGroup; children = ( @@ -2264,8 +2290,10 @@ 4E90F7622CC72B1F00417C31 /* EditServerTaskView */, 4E35CE622CBED3FF00DBD886 /* ServerLogsView */, 4E182C9A2C94991800FBEFD5 /* ServerTasksView */, - 4EF3D80A2CF7D6670081AD20 /* ServerUserAccessView */, 4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */, + 4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */, + 4E537A8C2D04410E00659A1A /* ServerUserLiveTVAccessView */, + 4EF3D80A2CF7D6670081AD20 /* ServerUserAccessView */, 4EB538B32CE3C75900EB72D5 /* ServerUserPermissionsView */, 4EC2B1992CC96E5E00D866BE /* ServerUsersView */, ); @@ -4228,6 +4256,7 @@ E1153DCB2BBB633B00424D36 /* FastSVGView.swift */, 531AC8BE26750DE20091C7EB /* ImageView.swift */, 4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */, + 4E24ECFA2D076F2B00A473A9 /* ListRowCheckbox.swift */, E1D37F472B9C648E00343D2B /* MaxHeightText.swift */, E1DC983F296DEBA500982F06 /* PosterIndicators */, E1FE69A628C29B720021BC93 /* ProgressBar.swift */, @@ -4236,6 +4265,7 @@ E18E01FF288749200022598C /* RowDivider.swift */, E1E1643D28BB074000323B0A /* SelectorView.swift */, E1356E0129A7309D00382563 /* SeparatorHStack.swift */, + E1DD95CB2D07876400335494 /* SeparatorVStack.swift */, E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */, E1A1528928FD22F600600579 /* TextPairView.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */, @@ -5005,6 +5035,7 @@ E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */, E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, 4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */, + 4E24ECFC2D076F6200A473A9 /* ListRowCheckbox.swift in Sources */, E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */, E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */, E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */, @@ -5302,6 +5333,7 @@ E1CB75702C80E66700217C76 /* CommaStringBuilder.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */, + E1DD95CC2D07876400335494 /* SeparatorVStack.swift in Sources */, E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */, E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */, E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */, @@ -5446,6 +5478,7 @@ C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */, 4E661A322CEFE7BC00025C99 /* SeriesStatus.swift in Sources */, E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, + 4E537A8D2D04410E00659A1A /* ServerUserLiveTVAccessView.swift in Sources */, BD39577E2C1140810078CEF8 /* TransitionSection.swift in Sources */, 4EC2B1A52CC96FA400D866BE /* ServerUserAdminViewModel.swift in Sources */, E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */, @@ -5704,6 +5737,7 @@ E10B1ECA2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */, 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */, + 4E537A842D03D11200659A1A /* ServerUserDeviceAccessView.swift in Sources */, 4E35CE692CBED95F00DBD886 /* DayOfWeek.swift in Sources */, E18E01E3288747230022598C /* CompactPortraitScrollView.swift in Sources */, 4E026A8B2CE804E7005471B5 /* ResetUserPasswordView.swift in Sources */, @@ -5715,6 +5749,7 @@ 4EB538BD2CE3CCD100EB72D5 /* MediaPlaybackSection.swift in Sources */, 4EBE064D2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */, E1366A222C826DA700A36DED /* EditCustomDeviceProfileCoordinator.swift in Sources */, + 4E24ECFB2D076F6200A473A9 /* ListRowCheckbox.swift in Sources */, E1CCF12E28ABF989006CAC9E /* PosterDisplayType.swift in Sources */, E10B1EC12BD9AD6100A92EAF /* V1UserModel.swift in Sources */, E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */, @@ -5754,6 +5789,7 @@ E18ACA952A15A3E100BB4F35 /* (null) in Sources */, 4EB1A8CE2C9B2D0800F43898 /* ActiveSessionRow.swift in Sources */, E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */, + E1DD95CD2D07876400335494 /* SeparatorVStack.swift in Sources */, E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */, E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */, E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */, diff --git a/Swiftfin/Components/ListTitleSection.swift b/Swiftfin/Components/ListTitleSection.swift index 88b4576b..752f0d81 100644 --- a/Swiftfin/Components/ListTitleSection.swift +++ b/Swiftfin/Components/ListTitleSection.swift @@ -67,67 +67,122 @@ extension ListTitleSection { } } -struct InsetGroupedListHeader: View { +/// A view that mimics an inset grouped section, meant to be +/// used as a header for a `List` with `listStyle(.plain)`. +struct InsetGroupedListHeader: View { @Default(.accentColor) private var accentColor - private let title: String - private let description: String? + private let content: () -> Content + private let title: Text? + private let description: Text? private let onLearnMore: (() -> Void)? - var body: some View { + @ViewBuilder + private var header: some View { Button { onLearnMore?() } label: { - ZStack { - RoundedRectangle(cornerRadius: 16) - .fill(Color.secondarySystemBackground) + VStack(alignment: .center, spacing: 10) { - VStack(alignment: .center, spacing: 10) { - - Text(title) + if let title { + title .font(.title3) .fontWeight(.semibold) - - if let description { - Text(description) - .multilineTextAlignment(.center) - } - - if onLearnMore != nil { - Text("Learn More\u{2026}") - .foregroundStyle(accentColor) - } } - .font(.subheadline) - .frame(maxWidth: .infinity) - .padding(16) + + if let description { + description + .multilineTextAlignment(.center) + } + + if onLearnMore != nil { + Text("Learn More\u{2026}") + .foregroundStyle(accentColor) + } } + .font(.subheadline) + .frame(maxWidth: .infinity) + .padding(16) } .foregroundStyle(.primary, .secondary) } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.secondarySystemBackground) + + SeparatorVStack { + RowDivider() + } content: { + if title != nil || description != nil { + header + } + + content() + .listRowSeparator(.hidden) + .padding(.init(vertical: 5, horizontal: 20)) + .listRowInsets(.init(vertical: 10, horizontal: 20)) + } + } + } } extension InsetGroupedListHeader { init( - _ title: String, - description: String? = nil + _ title: String? = nil, + description: String? = nil, + onLearnMore: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content ) { self.init( - title: title, - description: description, - onLearnMore: nil + content: content, + title: title == nil ? nil : Text(title!), + description: description == nil ? nil : Text(description!), + onLearnMore: onLearnMore ) } init( - _ title: String, - description: String? = nil, - onLearnMore: @escaping () -> Void + title: Text, + description: Text? = nil, + onLearnMore: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content ) { self.init( + content: content, + title: title, + description: description, + onLearnMore: onLearnMore + ) + } +} + +extension InsetGroupedListHeader where Content == EmptyView { + + init( + _ title: String, + description: String? = nil, + onLearnMore: (() -> Void)? = nil + ) { + self.init( + content: { EmptyView() }, + title: Text(title), + description: description == nil ? nil : Text(description!), + onLearnMore: onLearnMore + ) + } + + init( + title: Text, + description: Text? = nil, + onLearnMore: (() -> Void)? = nil + ) { + self.init( + content: { EmptyView() }, title: title, description: description, onLearnMore: onLearnMore diff --git a/Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift b/Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift index a8ccaea0..2b40e715 100644 --- a/Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift +++ b/Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift @@ -30,17 +30,28 @@ extension DevicesView { @CurrentDate private var currentDate: Date - // MARK: - Observed Objects + // MARK: - Properties let device: DeviceInfo let onSelect: () -> Void - let onDelete: () -> Void + let onDelete: (() -> Void)? + + // MARK: - Initializer + + init( + device: DeviceInfo, + onSelect: @escaping () -> Void, + onDelete: (() -> Void)? = nil + ) { + self.device = device + self.onSelect = onSelect + self.onDelete = onDelete + } // MARK: - Label Styling private var labelForegroundStyle: some ShapeStyle { guard isEditing else { return .primary } - return isSelected ? .primary : .secondary } @@ -72,7 +83,6 @@ extension DevicesView { private var rowContent: some View { HStack { VStack(alignment: .leading) { - Text(device.name ?? L10n.unknown) .font(.headline) .lineLimit(2) @@ -82,17 +92,20 @@ extension DevicesView { leading: L10n.user, trailing: device.lastUserName ?? L10n.unknown ) + .lineLimit(1) TextPairView( leading: L10n.client, trailing: device.appName ?? L10n.unknown ) + .lineLimit(1) TextPairView( L10n.lastSeen, value: Text(device.dateLastActivity, format: .lastSeen) ) .id(currentDate) + .lineLimit(1) .monospacedDigit() } .font(.subheadline) @@ -100,45 +113,29 @@ extension DevicesView { Spacer() - if isEditing, isSelected { - Image(systemName: "checkmark.circle.fill") - .resizable() - .backport - .fontWeight(.bold) - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - - } else if isEditing { - Image(systemName: "circle") - .resizable() - .backport - .fontWeight(.bold) - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .foregroundStyle(.secondary) - } + ListRowCheckbox() } } // MARK: - Body var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + ListRow { deviceImage } content: { rowContent - .padding(.vertical, 8) } .onSelect(perform: onSelect) + .isSeparatorVisible(false) .swipeActions { - Button( - L10n.delete, - systemImage: "trash", - action: onDelete - ) - .tint(.red) + if let onDelete = onDelete { + Button( + L10n.delete, + systemImage: "trash", + action: onDelete + ) + .tint(.red) + } } } } diff --git a/Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift b/Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift index 2a463e3e..62e36d03 100644 --- a/Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift +++ b/Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift @@ -145,8 +145,7 @@ struct DevicesView: View { } .environment(\.isEditing, isEditing) .environment(\.isSelected, selectedDevices.contains(device.id ?? "")) - .listRowSeparator(.hidden) - .listRowInsets(.zero) + .listRowInsets(.edgeInsets) } } } diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift index 6ee41b83..f6926c65 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserAccessView/ServerUserAccessView.swift @@ -10,7 +10,7 @@ import Defaults import JellyfinAPI import SwiftUI -struct ServerUserAccessView: View { +struct ServerUserMediaAccessView: View { // MARK: - Environment diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift index 6c5b52d8..b2471eff 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDetailsView/ServerUserDetailsView.swift @@ -31,7 +31,6 @@ struct ServerUserDetailsView: View { var body: some View { List { - // TODO: Replace with Update Profile Picture & Username AdminDashboardView.UserSection( user: viewModel.user, @@ -47,25 +46,53 @@ struct ServerUserDetailsView: View { } } - Section { - ChevronButton(L10n.mediaAccess) - .onSelect { - router.route(to: \.userMediaAccess, viewModel) - } - + Section(L10n.advanced) { ChevronButton(L10n.permissions) .onSelect { router.route(to: \.userPermissions, viewModel) } - - // TODO: Access: enabledFolders & enableAllFolders - - // TODO: Deletion: enableContentDeletion & enableContentDeletionFromFolders - - // TODO: Parental: accessSchedules, maxParentalRating, blockUnratedItems, blockedTags, blockUnratedItems & blockedMediaFolders - - // TODO: Live TV: enabledChannels & enableAllChannels } + + Section(L10n.access) { + ChevronButton(L10n.devices) + .onSelect { + router.route(to: \.userDeviceAccess, viewModel) + } + ChevronButton(L10n.liveTV) + .onSelect { + router.route(to: \.userLiveTVAccess, viewModel) + } + ChevronButton(L10n.media) + .onSelect { + router.route(to: \.userMediaAccess, viewModel) + } + } + + /* 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") + .onSelect { + router.route(to: \.userAccessSchedules, viewModel) + } + + // TODO: Parental Rating - maxParentalRating, blockUnratedItems + ChevronButton("Parental rating") + .onSelect { + router.route(to: \.userParentalRating, viewModel) + } + } */ } .navigationTitle(L10n.user) .onAppear { diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift new file mode 100644 index 00000000..af7a4376 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift @@ -0,0 +1,130 @@ +// +// 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 ServerUserDeviceAccessView: View { + + // MARK: - Environment + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + // MARK: - ViewModel + + @StateObject + private var viewModel: ServerUserAdminViewModel + @StateObject + private var devicesViewModel = DevicesViewModel() + + // MARK: - State Variables + + @State + private var tempPolicy: UserPolicy + @State + private var error: Error? + @State + private var isPresentingError: Bool = false + + // MARK: - Current Date + + @CurrentDate + private var currentDate: Date + + // 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.deviceAccess) + .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) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + isPresentingError = true + case .updated: + UIDevice.feedback(.success) + router.dismissCoordinator() + } + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) {} + } message: { error in + Text(error.localizedDescription) + } + .onFirstAppear { + devicesViewModel.send(.getDevices) + } + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + List { + InsetGroupedListHeader { + Toggle( + L10n.enableAllDevices, + isOn: $tempPolicy.enableAllDevices.coalesce(false) + ) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 24) + + if tempPolicy.enableAllDevices == false { + Section { + ForEach(devicesViewModel.devices, id: \.self) { device in + DevicesView.DeviceRow(device: device) { + if let index = tempPolicy.enabledDevices?.firstIndex(of: device.id!) { + tempPolicy.enabledDevices?.remove(at: index) + } else { + if tempPolicy.enabledDevices == nil { + tempPolicy.enabledDevices = [] + } + tempPolicy.enabledDevices?.append(device.id!) + } + } + .environment(\.isEditing, true) + .environment(\.isSelected, tempPolicy.enabledDevices?.contains(device.id ?? "") == true) + } + } + } + } + .listStyle(.plain) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift b/Swiftfin/Views/AdminDashboardView/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift new file mode 100644 index 00000000..1d647b64 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift @@ -0,0 +1,106 @@ +// +// 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 ServerUserLiveTVAccessView: View { + + // MARK: - Environment + + @EnvironmentObject + private var router: BasicNavigationViewCoordinator.Router + + // MARK: - ViewModel + + @ObservedObject + private var viewModel: ServerUserAdminViewModel + + // MARK: - State Variables + + @State + private var tempPolicy: UserPolicy + @State + private var error: Error? + @State + private var isPresentingError: Bool = false + + // MARK: - Current Date + + @CurrentDate + private var currentDate: Date + + // MARK: - Initializer + + init(viewModel: ServerUserAdminViewModel) { + self.viewModel = viewModel + self.tempPolicy = viewModel.user.policy ?? UserPolicy() + } + + // MARK: - Body + + var body: some View { + contentView + .navigationTitle(L10n.tvAccess) + .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) + } + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + isPresentingError = true + case .updated: + UIDevice.feedback(.success) + router.dismissCoordinator() + } + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) {} + } message: { error in + Text(error.localizedDescription) + } + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + List { + Section(L10n.access) { + Toggle( + L10n.liveTvAccess, + isOn: $tempPolicy.enableLiveTvAccess.coalesce(false) + ) + Toggle( + L10n.liveTvRecordingManagement, + isOn: $tempPolicy.enableLiveTvManagement.coalesce(false) + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift index cae56d5f..fe006974 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsersView/Components/ServerUsersRow.swift @@ -133,38 +133,20 @@ extension ServerUsersView { Spacer() - if isEditing, isSelected { - Image(systemName: "checkmark.circle.fill") - .resizable() - .backport - .fontWeight(.bold) - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - - } else if isEditing { - Image(systemName: "circle") - .resizable() - .backport - .fontWeight(.bold) - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .foregroundStyle(.secondary) - } + ListRowCheckbox() } } // MARK: - Body var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + ListRow { userImage } content: { rowContent - .padding(.vertical, 8) } .onSelect(perform: onSelect) + .isSeparatorVisible(false) .swipeActions { Button( L10n.delete, diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsersView/ServerUsersView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsersView/ServerUsersView.swift index 8a727547..5dccc1f5 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsersView/ServerUsersView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsersView/ServerUsersView.swift @@ -189,8 +189,7 @@ struct ServerUsersView: View { } .environment(\.isEditing, isEditing) .environment(\.isSelected, selectedUsers.contains(userID)) - .listRowSeparator(.hidden) - .listRowInsets(.zero) + .listRowInsets(.edgeInsets) } } } diff --git a/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift b/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift index ec624f26..689413fc 100644 --- a/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift +++ b/Swiftfin/Views/ItemEditorView/EditItemElementView/Components/EditItemElementRow.swift @@ -27,15 +27,15 @@ extension EditItemElementView { // MARK: - Body var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + ListRow { if type == .people { personImage } } content: { rowContent } - .isSeparatorVisible(false) .onSelect(perform: onSelect) + .isSeparatorVisible(false) .swipeActions { Button(L10n.delete, systemImage: "trash", action: onDelete) .tint(.red) @@ -100,7 +100,7 @@ extension EditItemElementView { .posterStyle(.portrait) .posterShadow() .frame(width: 30, height: 90) - .padding(.trailing) + .padding(.horizontal) } } } diff --git a/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift b/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift index 21faec63..5e3e6e50 100644 --- a/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift +++ b/Swiftfin/Views/ItemEditorView/EditItemElementView/EditItemElementView.swift @@ -182,6 +182,7 @@ struct EditItemElementView: View { ) .environment(\.isEditing, isEditing) .environment(\.isSelected, selectedElements.contains(element)) + .listRowInsets(.edgeInsets) } .onMove { source, destination in guard isReordering else { return } diff --git a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/SeriesSection.swift b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/SeriesSection.swift index 8f22a559..5444f1aa 100644 --- a/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/SeriesSection.swift +++ b/Swiftfin/Views/ItemEditorView/EditMetadataView/Components/Sections/SeriesSection.swift @@ -100,7 +100,7 @@ extension EditMetadataView { @ViewBuilder private var runTimeView: some View { ChevronAlertButton( - L10n.runTime, + L10n.runtime, subtitle: ServerTicks(item.runTimeTicks ?? 0) .seconds.formatted(.hourMinute), description: L10n.episodeRuntimeDescription diff --git a/Swiftfin/Views/SelectUserView/Components/UserRow.swift b/Swiftfin/Views/SelectUserView/Components/UserRow.swift index a272a80c..75c696fc 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserRow.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserRow.swift @@ -117,25 +117,7 @@ extension SelectUserView { Spacer() - if isEditing, isSelected { - Image(systemName: "checkmark.circle.fill") - .resizable() - .backport - .fontWeight(.bold) - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - - } else if isEditing { - Image(systemName: "circle") - .resizable() - .backport - .fontWeight(.bold) - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .foregroundStyle(.secondary) - } + ListRowCheckbox() } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index ee9cc9e5..25ebb800 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -1817,10 +1817,6 @@ // Label for selecting the air time of an episode. "airTime" = "Air Time"; -// Run Time - Label for runtime input field -// Label for specifying episode runtime. -"runTime" = "Run Time"; - // Episode Runtime Description - Description for runtime input // Description displayed below runtime input for episodes. "episodeRuntimeDescription" = "Episode runtime in minutes."; @@ -1933,10 +1929,22 @@ // Toggle to enable a setting for all Libraries "enableAllLibraries" = "Enable all libraries"; +// Enable All Devices - Toggle +// Toggle to enable a setting for all devices +"enableAllDevices" = "Enable all devices"; + // Media Access - Section Title // Section Title for Server User Media Access Editing "mediaAccess" = "Media Access"; +// Device Access - Section Title +// Section Title for Server User Device Access Editing +"deviceAccess" = "Device Access"; + +// (Live) TV Access - Section Title +// Section Title for Server User Live TV Access Editing +"tvAccess" = "TV Access"; + // Deletion - Section Description // Section Title for Media Deletion "deletion" = "Deletion";