[iOS] Admin Dashboard - User Device & TV Access (#1342)

This commit is contained in:
Joe Kribs 2024-12-09 16:33:10 -07:00 committed by GitHub
parent c8acd780be
commit 174487a220
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 596 additions and 135 deletions

View File

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

View File

@ -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<Content: View, Separator: View>: 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()
}
}
}
}

View File

@ -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<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerUserDeviceAccessView(viewModel: viewModel)
}
}
func makeUserMediaAccess(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerUserAccessView(viewModel: viewModel)
ServerUserMediaAccessView(viewModel: viewModel)
}
}
func makeUserLiveTVAccess(viewModel: ServerUserAdminViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
ServerUserLiveTVAccessView(viewModel: viewModel)
}
}

View File

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

View File

@ -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 = "<group>"; };
4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = "<group>"; };
4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = "<group>"; };
4E24ECFA2D076F2B00A473A9 /* ListRowCheckbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowCheckbox.swift; sourceTree = "<group>"; };
4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileAction.swift; sourceTree = "<group>"; };
4E2AC4C12C6C491200DD600D /* AudoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudoCodec.swift; sourceTree = "<group>"; };
4E2AC4C42C6C492700DD600D /* MediaContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainer.swift; sourceTree = "<group>"; };
@ -1211,6 +1218,8 @@
4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenreEditorViewModel.swift; sourceTree = "<group>"; };
4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddItemElementView.swift; sourceTree = "<group>"; };
4E5334A12CD1A28400D59FA8 /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = "<group>"; };
4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserDeviceAccessView.swift; sourceTree = "<group>"; };
4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserLiveTVAccessView.swift; sourceTree = "<group>"; };
4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = "<group>"; };
4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = "<group>"; };
@ -1863,6 +1872,7 @@
E1DC9846296DEFF500982F06 /* FavoriteIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteIndicator.swift; sourceTree = "<group>"; };
E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddUserButton.swift; sourceTree = "<group>"; };
E1DD55362B6EE533007501C0 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
E1DD95CB2D07876400335494 /* SeparatorVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorVStack.swift; sourceTree = "<group>"; };
E1DE2B492B97ECB900F6715F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSection.swift; sourceTree = "<group>"; };
E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = "<group>"; };
@ -2148,8 +2158,8 @@
4E31EFA22CFFFB410053DFE7 /* EditItemElementView */ = {
isa = PBXGroup;
children = (
4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */,
4E31EFA32CFFFB480053DFE7 /* Components */,
4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */,
);
path = EditItemElementView;
sourceTree = "<group>";
@ -2249,6 +2259,22 @@
path = ActionButtons;
sourceTree = "<group>";
};
4E537A822D03D0FA00659A1A /* ServerUserDeviceAccessView */ = {
isa = PBXGroup;
children = (
4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */,
);
path = ServerUserDeviceAccessView;
sourceTree = "<group>";
};
4E537A8C2D04410E00659A1A /* ServerUserLiveTVAccessView */ = {
isa = PBXGroup;
children = (
4E537A8B2D04410E00659A1A /* ServerUserLiveTVAccessView.swift */,
);
path = ServerUserLiveTVAccessView;
sourceTree = "<group>";
};
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 */,

View File

@ -67,31 +67,33 @@ 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<Content: View>: 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) {
Text(title)
if let title {
title
.font(.title3)
.fontWeight(.semibold)
}
if let description {
Text(description)
description
.multilineTextAlignment(.center)
}
@ -104,30 +106,83 @@ struct InsetGroupedListHeader: View {
.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

View File

@ -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,39 +113,22 @@ 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 {
if let onDelete = onDelete {
Button(
L10n.delete,
systemImage: "trash",
@ -143,3 +139,4 @@ extension DevicesView {
}
}
}
}

View File

@ -145,8 +145,7 @@ struct DevicesView: View {
}
.environment(\.isEditing, isEditing)
.environment(\.isSelected, selectedDevices.contains(device.id ?? ""))
.listRowSeparator(.hidden)
.listRowInsets(.zero)
.listRowInsets(.edgeInsets)
}
}
}

View File

@ -10,7 +10,7 @@ import Defaults
import JellyfinAPI
import SwiftUI
struct ServerUserAccessView: View {
struct ServerUserMediaAccessView: View {
// MARK: - Environment

View File

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

View File

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

View File

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

View File

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

View File

@ -189,8 +189,7 @@ struct ServerUsersView: View {
}
.environment(\.isEditing, isEditing)
.environment(\.isSelected, selectedUsers.contains(userID))
.listRowSeparator(.hidden)
.listRowInsets(.zero)
.listRowInsets(.edgeInsets)
}
}
}

View File

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

View File

@ -182,6 +182,7 @@ struct EditItemElementView<Element: Hashable>: View {
)
.environment(\.isEditing, isEditing)
.environment(\.isSelected, selectedElements.contains(element))
.listRowInsets(.edgeInsets)
}
.onMove { source, destination in
guard isReordering else { return }

View File

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

View File

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

View File

@ -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";