mirror of
https://github.com/jellyfin/Swiftfin.git
synced 2024-11-27 00:00:37 +00:00
[iOS] Admin Dashboard - Users (#1287)
This commit is contained in:
parent
9e119017db
commit
e0990e321a
@ -77,6 +77,14 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||
@Route(.push)
|
||||
var serverLogs = makeServerLogs
|
||||
@Route(.push)
|
||||
var users = makeUsers
|
||||
@Route(.push)
|
||||
var userDetails = makeUserDetails
|
||||
@Route(.push)
|
||||
var userDevices = makeUserDevices
|
||||
@Route(.modal)
|
||||
var addServerUser = makeAddServerUser
|
||||
@Route(.push)
|
||||
var apiKeys = makeAPIKeys
|
||||
// <- End of AdminDashboard Items
|
||||
|
||||
@ -118,7 +126,8 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||
}
|
||||
|
||||
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
|
||||
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
|
||||
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator>
|
||||
{
|
||||
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
|
||||
}
|
||||
|
||||
@ -232,6 +241,27 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||
ServerLogsView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeUsers() -> some View {
|
||||
ServerUsersView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeUserDetails(user: UserDto) -> some View {
|
||||
ServerUserDetailsView(user: user)
|
||||
}
|
||||
|
||||
func makeAddServerUser() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
AddServerUserView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeUserDevices() -> some View {
|
||||
DevicesView()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeAPIKeys() -> some View {
|
||||
APIKeysView()
|
||||
@ -245,7 +275,6 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||
DebugSettingsView()
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
#if os(tvOS)
|
||||
|
@ -105,3 +105,27 @@ struct TimeIntervalFormatStyle: FormatStyle {
|
||||
).format(t ..< t.addingTimeInterval(value))
|
||||
}
|
||||
}
|
||||
|
||||
struct LastSeenFormatStyle: FormatStyle {
|
||||
|
||||
func format(_ value: Date?) -> String {
|
||||
|
||||
guard let value else {
|
||||
return L10n.never
|
||||
}
|
||||
|
||||
let timeInterval = Date.now.timeIntervalSince(value)
|
||||
let twentyFourHours: TimeInterval = 24 * 60 * 60
|
||||
|
||||
if timeInterval <= twentyFourHours {
|
||||
return value.formatted(.relative(presentation: .numeric, unitsStyle: .narrow))
|
||||
} else {
|
||||
return value.formatted(Date.FormatStyle.dateTime.year().month().day())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FormatStyle where Self == LastSeenFormatStyle {
|
||||
|
||||
static var lastSeen: LastSeenFormatStyle { LastSeenFormatStyle() }
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import JellyfinAPI
|
||||
|
||||
extension DeviceInfo {
|
||||
|
||||
var device: DeviceType {
|
||||
var type: DeviceType {
|
||||
DeviceType(
|
||||
client: appName,
|
||||
deviceName: name
|
||||
|
@ -24,6 +24,8 @@ extension Sequence {
|
||||
sorted(by: { $0[keyPath: keyPath] < $1[keyPath: keyPath] })
|
||||
}
|
||||
|
||||
// TODO: a flipped version of `sorted`
|
||||
|
||||
/// Returns the elements of the sequence, sorted by comparing values
|
||||
/// at the given `KeyPath` of `Element`.
|
||||
///
|
||||
|
@ -37,6 +37,8 @@ extension URL {
|
||||
|
||||
static let jellyfinDocsTasks: URL = URL(string: "https://jellyfin.org/docs/general/server/tasks")!
|
||||
|
||||
static let jellyfinDocsUsers: URL = URL(string: "https://jellyfin.org/docs/general/server/users")!
|
||||
|
||||
func isDirectoryAndReachable() throws -> Bool {
|
||||
guard try resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true else {
|
||||
return false
|
||||
|
@ -88,4 +88,6 @@ extension Notifications.Key {
|
||||
static let didChangeUserProfileImage = NotificationKey("didChangeUserProfileImage")
|
||||
|
||||
static let didStartPlayback = NotificationKey("didStartPlayback")
|
||||
|
||||
static let didAddServerUser = NotificationKey("didStartPlayback")
|
||||
}
|
||||
|
@ -18,8 +18,12 @@ internal enum L10n {
|
||||
internal static let accentColorDescription = L10n.tr("Localizable", "accentColorDescription", fallback: "Some views may need an app restart to update.")
|
||||
/// Accessibility
|
||||
internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility")
|
||||
/// Active
|
||||
internal static let active = L10n.tr("Localizable", "active", fallback: "Active")
|
||||
/// ActiveSessionsView Header
|
||||
internal static let activeDevices = L10n.tr("Localizable", "activeDevices", fallback: "Active Devices")
|
||||
/// Activity
|
||||
internal static let activity = L10n.tr("Localizable", "activity", fallback: "Activity")
|
||||
/// Add
|
||||
internal static let add = L10n.tr("Localizable", "add", fallback: "Add")
|
||||
/// Add API key
|
||||
@ -30,8 +34,12 @@ internal enum L10n {
|
||||
internal static let addTrigger = L10n.tr("Localizable", "addTrigger", fallback: "Add trigger")
|
||||
/// Add URL
|
||||
internal static let addURL = L10n.tr("Localizable", "addURL", fallback: "Add URL")
|
||||
/// Add User
|
||||
internal static let addUser = L10n.tr("Localizable", "addUser", fallback: "Add User")
|
||||
/// Administration Dashboard Section
|
||||
internal static let administration = L10n.tr("Localizable", "administration", fallback: "Administration")
|
||||
/// Administrator
|
||||
internal static let administrator = L10n.tr("Localizable", "administrator", fallback: "Administrator")
|
||||
/// Advanced
|
||||
internal static let advanced = L10n.tr("Localizable", "advanced", fallback: "Advanced")
|
||||
/// Airs %s
|
||||
@ -46,6 +54,8 @@ internal enum L10n {
|
||||
internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media")
|
||||
/// Select Server View - Select All Servers
|
||||
internal static let allServers = L10n.tr("Localizable", "allServers", fallback: "All Servers")
|
||||
/// View and manage all registered users on the server, including their permissions and activity status.
|
||||
internal static let allUsersDescription = L10n.tr("Localizable", "allUsersDescription", fallback: "View and manage all registered users on the server, including their permissions and activity status.")
|
||||
/// TranscodeReason - Anamorphic Video Not Supported
|
||||
internal static let anamorphicVideoNotSupported = L10n.tr("Localizable", "anamorphicVideoNotSupported", fallback: "Anamorphic video is not supported")
|
||||
/// API Key Copied
|
||||
@ -210,6 +220,8 @@ internal enum L10n {
|
||||
internal static let confirm = L10n.tr("Localizable", "confirm", fallback: "Confirm")
|
||||
/// Confirm Close
|
||||
internal static let confirmClose = L10n.tr("Localizable", "confirmClose", fallback: "Confirm Close")
|
||||
/// Confirm Password
|
||||
internal static let confirmPassword = L10n.tr("Localizable", "confirmPassword", fallback: "Confirm Password")
|
||||
/// Connect
|
||||
internal static let connect = L10n.tr("Localizable", "connect", fallback: "Connect")
|
||||
/// Connect Manually
|
||||
@ -290,14 +302,28 @@ internal enum L10n {
|
||||
internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out.")
|
||||
/// Delete Selected Devices
|
||||
internal static let deleteSelectedDevices = L10n.tr("Localizable", "deleteSelectedDevices", fallback: "Delete Selected Devices")
|
||||
/// Delete Selected Users
|
||||
internal static let deleteSelectedUsers = L10n.tr("Localizable", "deleteSelectedUsers", fallback: "Delete Selected Users")
|
||||
/// Are you sure you wish to delete all selected devices? All selected sessions will be logged out.
|
||||
internal static let deleteSelectionDevicesWarning = L10n.tr("Localizable", "deleteSelectionDevicesWarning", fallback: "Are you sure you wish to delete all selected devices? All selected sessions will be logged out.")
|
||||
/// Are you sure you wish to delete all selected users?
|
||||
internal static let deleteSelectionUsersWarning = L10n.tr("Localizable", "deleteSelectionUsersWarning", fallback: "Are you sure you wish to delete all selected users?")
|
||||
/// Server Detail View - Delete Server
|
||||
internal static let deleteServer = L10n.tr("Localizable", "deleteServer", fallback: "Delete Server")
|
||||
/// Delete Trigger
|
||||
internal static let deleteTrigger = L10n.tr("Localizable", "deleteTrigger", fallback: "Delete Trigger")
|
||||
/// Are you sure you want to delete this trigger? This action cannot be undone.
|
||||
internal static let deleteTriggerConfirmationMessage = L10n.tr("Localizable", "deleteTriggerConfirmationMessage", fallback: "Are you sure you want to delete this trigger? This action cannot be undone.")
|
||||
/// Delete User
|
||||
internal static let deleteUser = L10n.tr("Localizable", "deleteUser", fallback: "Delete User")
|
||||
/// Failed to Delete User
|
||||
internal static let deleteUserFailed = L10n.tr("Localizable", "deleteUserFailed", fallback: "Failed to Delete User")
|
||||
/// Cannot delete a user from the same user (%1$@).
|
||||
internal static func deleteUserSelfDeletion(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "deleteUserSelfDeletion", String(describing: p1), fallback: "Cannot delete a user from the same user (%1$@).")
|
||||
}
|
||||
/// Are you sure you wish to delete this user?
|
||||
internal static let deleteUserWarning = L10n.tr("Localizable", "deleteUserWarning", fallback: "Are you sure you wish to delete this user?")
|
||||
/// Delivery
|
||||
internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery")
|
||||
/// Details
|
||||
@ -338,6 +364,8 @@ internal enum L10n {
|
||||
internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: "Edit Jump Lengths")
|
||||
/// Select Server View - Edit an Existing Server
|
||||
internal static let editServer = L10n.tr("Localizable", "editServer", fallback: "Edit Server")
|
||||
/// Edit Users
|
||||
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")
|
||||
/// Enabled
|
||||
@ -392,6 +420,8 @@ internal enum L10n {
|
||||
internal static let grid = L10n.tr("Localizable", "grid", fallback: "Grid")
|
||||
/// Haptic Feedback
|
||||
internal static let hapticFeedback = L10n.tr("Localizable", "hapticFeedback", fallback: "Haptic Feedback")
|
||||
/// Hidden
|
||||
internal static let hidden = L10n.tr("Localizable", "hidden", fallback: "Hidden")
|
||||
/// Home
|
||||
internal static let home = L10n.tr("Localizable", "home", fallback: "Home")
|
||||
/// Hours
|
||||
@ -522,6 +552,8 @@ internal enum L10n {
|
||||
internal static let neverRun = L10n.tr("Localizable", "neverRun", fallback: "Never run")
|
||||
/// News
|
||||
internal static let news = L10n.tr("Localizable", "news", fallback: "News")
|
||||
/// New User
|
||||
internal static let newUser = L10n.tr("Localizable", "newUser", fallback: "New User")
|
||||
/// Next
|
||||
internal static let next = L10n.tr("Localizable", "next", fallback: "Next")
|
||||
/// Next Item
|
||||
@ -580,6 +612,8 @@ internal enum L10n {
|
||||
internal static let onNow = L10n.tr("Localizable", "onNow", fallback: "On Now")
|
||||
/// Operating System
|
||||
internal static let operatingSystem = L10n.tr("Localizable", "operatingSystem", fallback: "Operating System")
|
||||
/// Options
|
||||
internal static let options = L10n.tr("Localizable", "options", fallback: "Options")
|
||||
/// Orange
|
||||
internal static let orange = L10n.tr("Localizable", "orange", fallback: "Orange")
|
||||
/// Order
|
||||
@ -602,6 +636,8 @@ internal enum L10n {
|
||||
}
|
||||
/// Password
|
||||
internal static let password = L10n.tr("Localizable", "password", fallback: "Password")
|
||||
/// New passwords do not match
|
||||
internal static let passwordsDoNotMatch = L10n.tr("Localizable", "passwordsDoNotMatch", fallback: "New passwords do not match")
|
||||
/// Video Player Settings View - Pause on Background
|
||||
internal static let pauseOnBackground = L10n.tr("Localizable", "pauseOnBackground", fallback: "Pause on background")
|
||||
/// People
|
||||
@ -738,6 +774,8 @@ internal enum L10n {
|
||||
internal static let retry = L10n.tr("Localizable", "retry", fallback: "Retry")
|
||||
/// Right
|
||||
internal static let `right` = L10n.tr("Localizable", "right", fallback: "Right")
|
||||
/// Role
|
||||
internal static let role = L10n.tr("Localizable", "role", fallback: "Role")
|
||||
/// Button label to run a task
|
||||
internal static let run = L10n.tr("Localizable", "run", fallback: "Run")
|
||||
/// Status label for when a task is running
|
||||
@ -1014,6 +1052,10 @@ internal enum L10n {
|
||||
}
|
||||
/// Username
|
||||
internal static let username = L10n.tr("Localizable", "username", fallback: "Username")
|
||||
/// A username is required
|
||||
internal static let usernameRequired = L10n.tr("Localizable", "usernameRequired", fallback: "A username is required")
|
||||
/// Users
|
||||
internal static let users = L10n.tr("Localizable", "users", fallback: "Users")
|
||||
/// Version
|
||||
internal static let version = L10n.tr("Localizable", "version", fallback: "Version")
|
||||
/// Video
|
||||
|
93
Shared/ViewModels/AddServerUserViewModel.swift
Normal file
93
Shared/ViewModels/AddServerUserViewModel.swift
Normal file
@ -0,0 +1,93 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import OrderedCollections
|
||||
import SwiftUI
|
||||
|
||||
final class AddServerUserViewModel: ViewModel, Eventful, Stateful, Identifiable {
|
||||
|
||||
// MARK: Event
|
||||
|
||||
enum Event {
|
||||
case createdNewUser(UserDto)
|
||||
case error(JellyfinAPIError)
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
enum Action: Equatable {
|
||||
case cancel
|
||||
case createUser(username: String, password: String)
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
enum State: Hashable {
|
||||
case initial
|
||||
case creatingUser
|
||||
case error(JellyfinAPIError)
|
||||
}
|
||||
|
||||
// MARK: Published Values
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
@Published
|
||||
final var state: State = .initial
|
||||
|
||||
private var userTask: AnyCancellable?
|
||||
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
||||
|
||||
// MARK: - Respond to Action
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case .cancel:
|
||||
userTask?.cancel()
|
||||
return .initial
|
||||
case let .createUser(username, password):
|
||||
userTask?.cancel()
|
||||
|
||||
userTask = Task {
|
||||
do {
|
||||
let newUser = try await createUser(username: username, password: password)
|
||||
|
||||
await MainActor.run {
|
||||
state = .initial
|
||||
eventSubject.send(.createdNewUser(newUser))
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
state = .error(.init(error.localizedDescription))
|
||||
eventSubject.send(.error(.init(error.localizedDescription)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .creatingUser
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create User
|
||||
|
||||
private func createUser(username: String, password: String) async throws -> UserDto {
|
||||
let parameters = CreateUserByName(name: username, password: password)
|
||||
let request = Paths.createUserByName(parameters)
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
return response.value
|
||||
}
|
||||
}
|
102
Shared/ViewModels/DeviceDetailViewModel.swift
Normal file
102
Shared/ViewModels/DeviceDetailViewModel.swift
Normal file
@ -0,0 +1,102 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import OrderedCollections
|
||||
|
||||
class DeviceDetailViewModel: ViewModel, Stateful, Eventful {
|
||||
|
||||
enum Event {
|
||||
case error(JellyfinAPIError)
|
||||
case setCustomName
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case setCustomName(String)
|
||||
}
|
||||
|
||||
enum BackgroundState: Hashable {
|
||||
case updating
|
||||
}
|
||||
|
||||
enum State: Hashable {
|
||||
case initial
|
||||
}
|
||||
|
||||
@Published
|
||||
var backgroundStates: OrderedSet<BackgroundState> = []
|
||||
@Published
|
||||
var state: State = .initial
|
||||
|
||||
@Published
|
||||
private(set) var device: DeviceInfo
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
||||
|
||||
init(device: DeviceInfo) {
|
||||
self.device = device
|
||||
}
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case let .setCustomName(newName):
|
||||
cancellables = []
|
||||
|
||||
Task {
|
||||
await MainActor.run {
|
||||
_ = backgroundStates.append(.updating)
|
||||
}
|
||||
|
||||
do {
|
||||
try await setCustomName(newName: newName)
|
||||
|
||||
await MainActor.run {
|
||||
self.eventSubject.send(.setCustomName)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.eventSubject.send(.error(.init("Unable to update custom name")))
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
_ = backgroundStates.remove(.updating)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
return .initial
|
||||
}
|
||||
}
|
||||
|
||||
private func setCustomName(newName: String) async throws {
|
||||
guard let id = device.id else { return }
|
||||
|
||||
let request = Paths.updateDeviceOptions(id: id, .init(customName: newName))
|
||||
try await userSession.client.send(request)
|
||||
}
|
||||
|
||||
private func getDeviceInfo() async throws {
|
||||
guard let id = device.id else { return }
|
||||
|
||||
let request = Paths.getDeviceInfo(id: id)
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
await MainActor.run {
|
||||
self.device = response.value
|
||||
}
|
||||
}
|
||||
}
|
@ -25,7 +25,6 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful {
|
||||
|
||||
enum Action: Equatable {
|
||||
case getDevices
|
||||
case setCustomName(id: String, newName: String)
|
||||
case deleteDevices(ids: [String])
|
||||
}
|
||||
|
||||
@ -47,31 +46,22 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful {
|
||||
|
||||
// MARK: Published Values
|
||||
|
||||
@Published
|
||||
final var backgroundStates: OrderedSet<BackgroundState> = []
|
||||
@Published
|
||||
final var devices: [DeviceInfo] = []
|
||||
@Published
|
||||
final var state: State = .initial
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
@Published
|
||||
final var backgroundStates: OrderedSet<BackgroundState> = []
|
||||
@Published
|
||||
final var devices: OrderedDictionary<String, BindingBox<DeviceInfo?>> = [:]
|
||||
@Published
|
||||
final var state: State = .initial
|
||||
|
||||
@Published
|
||||
private(set) var userID: String?
|
||||
|
||||
private var deviceTask: AnyCancellable?
|
||||
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(_ userID: String? = nil) {
|
||||
self.userID = userID
|
||||
}
|
||||
|
||||
// MARK: - Respond to Action
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
@ -83,9 +73,8 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful {
|
||||
|
||||
deviceTask = Task { [weak self] in
|
||||
do {
|
||||
try await self?.loadDevices(
|
||||
userID: self?.userID
|
||||
)
|
||||
try await self?.loadDevices()
|
||||
|
||||
await MainActor.run {
|
||||
self?.state = .content
|
||||
self?.eventSubject.send(.success)
|
||||
@ -100,42 +89,12 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
let _ = self?.backgroundStates.remove(.gettingDevices)
|
||||
_ = self?.backgroundStates.remove(.gettingDevices)
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return state
|
||||
|
||||
case let .setCustomName(id, newName):
|
||||
deviceTask?.cancel()
|
||||
|
||||
backgroundStates.append(.settingCustomName)
|
||||
|
||||
deviceTask = Task { [weak self] in
|
||||
do {
|
||||
try await self?.setCustomName(id: id, newName: newName)
|
||||
await MainActor.run {
|
||||
self?.state = .content
|
||||
self?.eventSubject.send(.success)
|
||||
}
|
||||
} catch {
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
let jellyfinError = JellyfinAPIError(error.localizedDescription)
|
||||
self.state = .error(jellyfinError)
|
||||
self.eventSubject.send(.error(jellyfinError))
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
let _ = self?.backgroundStates.remove(.settingCustomName)
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return state
|
||||
|
||||
case let .deleteDevices(ids):
|
||||
deviceTask?.cancel()
|
||||
|
||||
@ -157,7 +116,7 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
let _ = self?.backgroundStates.remove(.deletingDevices)
|
||||
_ = self?.backgroundStates.remove(.deletingDevices)
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
@ -168,8 +127,8 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful {
|
||||
|
||||
// MARK: - Load Devices
|
||||
|
||||
private func loadDevices(userID: String?) async throws {
|
||||
let request = Paths.getDevices(userID: userID)
|
||||
private func loadDevices() async throws {
|
||||
let request = Paths.getDevices()
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
guard let devices = response.value.items else {
|
||||
@ -177,36 +136,8 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
for device in devices {
|
||||
guard let id = device.id else { continue }
|
||||
|
||||
if let existingDevice = self.devices[id] {
|
||||
existingDevice.value = device
|
||||
} else {
|
||||
self.devices[id] = BindingBox<DeviceInfo?>(
|
||||
source: .init(get: { device }, set: { _ in })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
self.devices.sort { x, y in
|
||||
let device0 = x.value.value
|
||||
let device1 = y.value.value
|
||||
return (device0?.dateLastActivity ?? Date()) > (device1?.dateLastActivity ?? Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Set Custom Name
|
||||
|
||||
private func setCustomName(id: String, newName: String) async throws {
|
||||
let request = Paths.updateDeviceOptions(id: id, DeviceOptionsDto(customName: newName))
|
||||
try await userSession.client.send(request)
|
||||
|
||||
if let _ = devices[id]?.value {
|
||||
await MainActor.run {
|
||||
self.devices[id]?.value?.name = newName
|
||||
}
|
||||
self.devices = devices.sorted(using: \.dateLastActivity)
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@ -221,9 +152,7 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful {
|
||||
let request = Paths.deleteDevice(id: id)
|
||||
try await userSession.client.send(request)
|
||||
|
||||
await MainActor.run {
|
||||
let _ = self.devices.removeValue(forKey: id)
|
||||
}
|
||||
try await loadDevices()
|
||||
}
|
||||
|
||||
// MARK: - Delete Devices
|
||||
@ -246,10 +175,6 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful {
|
||||
try await group.waitForAll()
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.devices = self.devices.filter {
|
||||
!deviceIdsToDelete.contains($0.key)
|
||||
}
|
||||
}
|
||||
try await loadDevices()
|
||||
}
|
||||
}
|
||||
|
244
Shared/ViewModels/ServerUserAdminViewModel.swift
Normal file
244
Shared/ViewModels/ServerUserAdminViewModel.swift
Normal file
@ -0,0 +1,244 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import OrderedCollections
|
||||
|
||||
final class ServerUserAdminViewModel: ViewModel, Eventful, Stateful, Identifiable {
|
||||
|
||||
// MARK: Event
|
||||
|
||||
enum Event {
|
||||
case success
|
||||
}
|
||||
|
||||
// MARK: BackgroundState
|
||||
|
||||
enum BackgroundState {
|
||||
case updating
|
||||
}
|
||||
|
||||
// MARK: Action
|
||||
|
||||
enum Action: Equatable {
|
||||
case cancel
|
||||
case loadDetails
|
||||
case resetPassword
|
||||
case updatePassword(password: String)
|
||||
case updatePolicy(policy: UserPolicy)
|
||||
case updateConfiguration(configuration: UserConfiguration)
|
||||
}
|
||||
|
||||
// MARK: State
|
||||
|
||||
enum State: Hashable {
|
||||
case error(JellyfinAPIError)
|
||||
case initial
|
||||
}
|
||||
|
||||
// MARK: Published Values
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
@Published
|
||||
final var backgroundStates: OrderedSet<BackgroundState> = []
|
||||
@Published
|
||||
final var state: State = .initial
|
||||
@Published
|
||||
private(set) var user: UserDto
|
||||
|
||||
private var resetTask: AnyCancellable?
|
||||
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
||||
|
||||
// MARK: Initialize from UserDto
|
||||
|
||||
init(user: UserDto) {
|
||||
self.user = user
|
||||
}
|
||||
|
||||
// MARK: Respond
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case .cancel:
|
||||
resetTask?.cancel()
|
||||
return .initial
|
||||
|
||||
case .resetPassword:
|
||||
resetTask = Task {
|
||||
await MainActor.run {
|
||||
_ = self.backgroundStates.append(.updating)
|
||||
}
|
||||
|
||||
do {
|
||||
try await resetPassword()
|
||||
await MainActor.run {
|
||||
self.state = .initial
|
||||
self.eventSubject.send(.success)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
let jellyfinError = JellyfinAPIError(error.localizedDescription)
|
||||
self.state = .error(jellyfinError)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
_ = self.backgroundStates.remove(.updating)
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .initial
|
||||
|
||||
case .loadDetails:
|
||||
resetTask = Task {
|
||||
do {
|
||||
try await loadDetails()
|
||||
await MainActor.run {
|
||||
self.state = .initial
|
||||
self.eventSubject.send(.success)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
let jellyfinError = JellyfinAPIError(error.localizedDescription)
|
||||
self.state = .error(jellyfinError)
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .initial
|
||||
|
||||
case let .updatePassword(password):
|
||||
resetTask = Task {
|
||||
do {
|
||||
try await updatePassword(password: password)
|
||||
await MainActor.run {
|
||||
self.state = .initial
|
||||
self.eventSubject.send(.success)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
let jellyfinError = JellyfinAPIError(error.localizedDescription)
|
||||
self.state = .error(jellyfinError)
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .initial
|
||||
|
||||
case let .updatePolicy(policy):
|
||||
resetTask = Task {
|
||||
do {
|
||||
try await updatePolicy(policy: policy)
|
||||
await MainActor.run {
|
||||
self.state = .initial
|
||||
self.eventSubject.send(.success)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
let jellyfinError = JellyfinAPIError(error.localizedDescription)
|
||||
self.state = .error(jellyfinError)
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .initial
|
||||
|
||||
case let .updateConfiguration(configuration):
|
||||
resetTask = Task {
|
||||
do {
|
||||
try await updateConfiguration(configuration: configuration)
|
||||
await MainActor.run {
|
||||
self.state = .initial
|
||||
self.eventSubject.send(.success)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
let jellyfinError = JellyfinAPIError(error.localizedDescription)
|
||||
self.state = .error(jellyfinError)
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .initial
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reset Password
|
||||
|
||||
private func resetPassword() async throws {
|
||||
guard let userId = user.id else { return }
|
||||
let parameters = UpdateUserPassword(isResetPassword: true)
|
||||
let request = Paths.updateUserPassword(userID: userId, parameters)
|
||||
try await userSession.client.send(request)
|
||||
|
||||
await MainActor.run {
|
||||
self.user.hasPassword = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update Password
|
||||
|
||||
private func updatePassword(password: String) async throws {
|
||||
guard let userID = user.id else { return }
|
||||
let parameters = UpdateUserPassword(newPw: password)
|
||||
let request = Paths.updateUserPassword(userID: userID, parameters)
|
||||
try await userSession.client.send(request)
|
||||
|
||||
await MainActor.run {
|
||||
self.user.hasPassword = (password != "")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update User Policy
|
||||
|
||||
private func updatePolicy(policy: UserPolicy) async throws {
|
||||
guard let userID = user.id else { return }
|
||||
let request = Paths.updateUserPolicy(userID: userID, policy)
|
||||
try await userSession.client.send(request)
|
||||
|
||||
await MainActor.run {
|
||||
self.user.policy = policy
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update User Configuration
|
||||
|
||||
private func updateConfiguration(configuration: UserConfiguration) async throws {
|
||||
guard let userID = user.id else { return }
|
||||
let request = Paths.updateUserConfiguration(userID: userID, configuration)
|
||||
try await userSession.client.send(request)
|
||||
|
||||
await MainActor.run {
|
||||
self.user.configuration = configuration
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Load User
|
||||
|
||||
private func loadDetails() async throws {
|
||||
guard let userID = user.id else { return }
|
||||
let request = Paths.getUserByID(userID: userID)
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
await MainActor.run {
|
||||
self.user = response.value
|
||||
}
|
||||
}
|
||||
}
|
203
Shared/ViewModels/ServerUsersViewModel.swift
Normal file
203
Shared/ViewModels/ServerUsersViewModel.swift
Normal file
@ -0,0 +1,203 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import OrderedCollections
|
||||
import SwiftUI
|
||||
|
||||
final class ServerUsersViewModel: ViewModel, Eventful, Stateful, Identifiable {
|
||||
|
||||
// MARK: Event
|
||||
|
||||
enum Event {
|
||||
case deleted
|
||||
case error(JellyfinAPIError)
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
enum Action: Equatable {
|
||||
case getUsers(isHidden: Bool = false, isDisabled: Bool = false)
|
||||
case deleteUsers([String])
|
||||
case appendUser(UserDto)
|
||||
}
|
||||
|
||||
// MARK: - BackgroundState
|
||||
|
||||
enum BackgroundState: Hashable {
|
||||
case gettingUsers
|
||||
case deletingUsers
|
||||
case appendingUsers
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
enum State: Hashable {
|
||||
case content
|
||||
case error(JellyfinAPIError)
|
||||
case initial
|
||||
}
|
||||
|
||||
// MARK: Published Values
|
||||
|
||||
@Published
|
||||
final var backgroundStates: OrderedSet<BackgroundState> = []
|
||||
@Published
|
||||
final var users: [UserDto] = []
|
||||
@Published
|
||||
final var state: State = .initial
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var userTask: AnyCancellable?
|
||||
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
||||
|
||||
// MARK: - Respond to Action
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case let .getUsers(isHidden, isDisabled):
|
||||
userTask?.cancel()
|
||||
backgroundStates.append(.gettingUsers)
|
||||
|
||||
userTask = Task {
|
||||
do {
|
||||
try await loadUsers(isHidden: isHidden, isDisabled: isDisabled)
|
||||
|
||||
await MainActor.run {
|
||||
state = .content
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.state = .error(.init(error.localizedDescription))
|
||||
self.eventSubject.send(.error(.init(error.localizedDescription)))
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
_ = self.backgroundStates.remove(.gettingUsers)
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return state
|
||||
|
||||
case let .deleteUsers(ids):
|
||||
userTask?.cancel()
|
||||
backgroundStates.append(.deletingUsers)
|
||||
|
||||
userTask = Task {
|
||||
do {
|
||||
try await self.deleteUsers(ids: ids)
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
self.eventSubject.send(.deleted)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.state = .error(.init(error.localizedDescription))
|
||||
self.eventSubject.send(.error(.init(error.localizedDescription)))
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
_ = self.backgroundStates.remove(.deletingUsers)
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return state
|
||||
|
||||
case let .appendUser(user):
|
||||
userTask?.cancel()
|
||||
backgroundStates.append(.appendingUsers)
|
||||
|
||||
userTask = Task {
|
||||
do {
|
||||
await self.appendUser(user: user)
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
self.eventSubject.send(.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
_ = self.backgroundStates.remove(.appendingUsers)
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Load Users
|
||||
|
||||
private func loadUsers(isHidden: Bool, isDisabled: Bool) async throws {
|
||||
let request = Paths.getUsers(isHidden: isHidden ? true : nil, isDisabled: isDisabled ? true : nil)
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
let newUsers = response.value
|
||||
.sorted(using: \.name)
|
||||
|
||||
await MainActor.run {
|
||||
self.users = newUsers
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete Users
|
||||
|
||||
private func deleteUsers(ids: [String]) async throws {
|
||||
guard ids.isNotEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't allow self-deletion
|
||||
let userIdsToDelete = ids.filter { $0 != userSession.user.id }
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
for userId in userIdsToDelete {
|
||||
group.addTask {
|
||||
try await self.deleteUser(id: userId)
|
||||
}
|
||||
}
|
||||
|
||||
try await group.waitForAll()
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.users = self.users.filter {
|
||||
!userIdsToDelete.contains($0.id ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete User
|
||||
|
||||
private func deleteUser(id: String) async throws {
|
||||
let request = Paths.deleteUser(userID: id)
|
||||
try await userSession.client.send(request)
|
||||
}
|
||||
|
||||
// MARK: - Append User
|
||||
|
||||
private func appendUser(user: UserDto) async {
|
||||
await MainActor.run {
|
||||
users.append(user)
|
||||
users = users.sorted(using: \.name)
|
||||
}
|
||||
}
|
||||
}
|
@ -85,6 +85,8 @@
|
||||
4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; };
|
||||
4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */; };
|
||||
4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; };
|
||||
4EA397462CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */; };
|
||||
4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */; };
|
||||
4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; };
|
||||
4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; };
|
||||
4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */; };
|
||||
@ -92,6 +94,7 @@
|
||||
4EB4ECE32CBEFC4D002FF2FC /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */; };
|
||||
4EB4ECE42CBEFC4D002FF2FC /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */; };
|
||||
4EB7B33B2CBDE645004A342E /* ChevronAlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */; };
|
||||
4EB7C8D52CCED6E7000CC011 /* AddServerUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7C8D42CCED6E1000CC011 /* AddServerUserView.swift */; };
|
||||
4EBE06462C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */; };
|
||||
4EBE06472C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */; };
|
||||
4EBE064D2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */; };
|
||||
@ -103,6 +106,12 @@
|
||||
4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8512C7FDFA300E2879E /* PlaybackDeviceProfile.swift */; };
|
||||
4EC1C8692C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */; };
|
||||
4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */; };
|
||||
4EC2B19B2CC96E7400D866BE /* ServerUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B19A2CC96E7000D866BE /* ServerUsersView.swift */; };
|
||||
4EC2B19E2CC96EAB00D866BE /* ServerUsersRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B19D2CC96EA300D866BE /* ServerUsersRow.swift */; };
|
||||
4EC2B1A22CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B1A12CC96F6000D866BE /* ServerUsersViewModel.swift */; };
|
||||
4EC2B1A32CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B1A12CC96F6000D866BE /* ServerUsersViewModel.swift */; };
|
||||
4EC2B1A52CC96FA400D866BE /* ServerUserAdminViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */; };
|
||||
4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC2B1A82CC97C0400D866BE /* ServerUserDetailsView.swift */; };
|
||||
4EC50D612C934B3A00FC3D0E /* ScheduledTasksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */; };
|
||||
4EC50D622C934B3A00FC3D0E /* ScheduledTasksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */; };
|
||||
4EC6C16B2C92999800FC904B /* TranscodeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */; };
|
||||
@ -279,6 +288,8 @@
|
||||
E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; };
|
||||
E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; };
|
||||
E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; };
|
||||
E101ECD52CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */; };
|
||||
E101ECD62CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */; };
|
||||
E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */; };
|
||||
E102312F2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */; };
|
||||
E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */; };
|
||||
@ -1115,12 +1126,14 @@
|
||||
4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = "<group>"; };
|
||||
4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = "<group>"; };
|
||||
4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysRow.swift; sourceTree = "<group>"; };
|
||||
4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserViewModel.swift; sourceTree = "<group>"; };
|
||||
4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = "<group>"; };
|
||||
4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = "<group>"; };
|
||||
4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = "<group>"; };
|
||||
4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionRow.swift; sourceTree = "<group>"; };
|
||||
4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = "<group>"; };
|
||||
4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronAlertButton.swift; sourceTree = "<group>"; };
|
||||
4EB7C8D42CCED6E1000CC011 /* AddServerUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserView.swift; sourceTree = "<group>"; };
|
||||
4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCompatibility.swift; sourceTree = "<group>"; };
|
||||
4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = "<group>"; };
|
||||
4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = "<group>"; };
|
||||
@ -1128,6 +1141,11 @@
|
||||
4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileCoordinator.swift; sourceTree = "<group>"; };
|
||||
4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = "<group>"; };
|
||||
4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = "<group>"; };
|
||||
4EC2B19A2CC96E7000D866BE /* ServerUsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUsersView.swift; sourceTree = "<group>"; };
|
||||
4EC2B19D2CC96EA300D866BE /* ServerUsersRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUsersRow.swift; sourceTree = "<group>"; };
|
||||
4EC2B1A12CC96F6000D866BE /* ServerUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUsersViewModel.swift; sourceTree = "<group>"; };
|
||||
4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAdminViewModel.swift; sourceTree = "<group>"; };
|
||||
4EC2B1A82CC97C0400D866BE /* ServerUserDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserDetailsView.swift; sourceTree = "<group>"; };
|
||||
4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTasksViewModel.swift; sourceTree = "<group>"; };
|
||||
4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = "<group>"; };
|
||||
4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = "<group>"; };
|
||||
@ -1281,6 +1299,7 @@
|
||||
C4E508172703E8190045C9AB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = "<group>"; };
|
||||
DFB7C3DE2C7AA42700CE7CDC /* UserSignInState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInState.swift; sourceTree = "<group>"; };
|
||||
E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = "<group>"; };
|
||||
E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailViewModel.swift; sourceTree = "<group>"; };
|
||||
E10231292BCF8A08009D71FC /* iOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = iOSLiveTVCoordinator.swift; sourceTree = "<group>"; };
|
||||
E102312A2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVCoordinator.swift; sourceTree = "<group>"; };
|
||||
E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramButtonContent.swift; sourceTree = "<group>"; };
|
||||
@ -1994,6 +2013,7 @@
|
||||
4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */,
|
||||
4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */,
|
||||
4EA09DDF2CC4E4D000CB27E4 /* APIKeyView */,
|
||||
4EB7C8D32CCED318000CC011 /* AddServerUserView */,
|
||||
4E35CE5B2CBED3F300DBD886 /* AddTaskTriggerView */,
|
||||
E1DE64902CC6F06C00E423B6 /* Components */,
|
||||
4E10C80F2CC030B20012CC9F /* DeviceDetailsView */,
|
||||
@ -2001,6 +2021,8 @@
|
||||
4E90F7622CC72B1F00417C31 /* EditServerTaskView */,
|
||||
4E182C9A2C94991800FBEFD5 /* ServerTasksView */,
|
||||
4E35CE622CBED3FF00DBD886 /* ServerLogsView */,
|
||||
4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */,
|
||||
4EC2B1992CC96E5E00D866BE /* ServerUsersView */,
|
||||
4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */,
|
||||
);
|
||||
path = UserDashboardView;
|
||||
@ -2158,6 +2180,14 @@
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EB7C8D32CCED318000CC011 /* AddServerUserView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EB7C8D42CCED6E1000CC011 /* AddServerUserView.swift */,
|
||||
);
|
||||
path = AddServerUserView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EC1C86A2C80900B00E2879E /* CustomDeviceProfileSettingsView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -2176,6 +2206,31 @@
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EC2B1992CC96E5E00D866BE /* ServerUsersView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EC2B19C2CC96E9400D866BE /* Components */,
|
||||
4EC2B19A2CC96E7000D866BE /* ServerUsersView.swift */,
|
||||
);
|
||||
path = ServerUsersView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EC2B19C2CC96E9400D866BE /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EC2B19D2CC96EA300D866BE /* ServerUsersRow.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EC2B1A72CC9725400D866BE /* ServerUserDetailsView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4EC2B1A82CC97C0400D866BE /* ServerUserDetailsView.swift */,
|
||||
);
|
||||
path = ServerUserDetailsView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EED87472CBF824B002354D2 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -2229,9 +2284,11 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */,
|
||||
4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */,
|
||||
4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */,
|
||||
E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */,
|
||||
625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */,
|
||||
E101ECD42CD40489001EA89E /* DeviceDetailViewModel.swift */,
|
||||
4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */,
|
||||
E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */,
|
||||
E113133928BEB71D00930F75 /* FilterViewModel.swift */,
|
||||
@ -2250,6 +2307,8 @@
|
||||
E173DA5326D050F500CC4EB7 /* ServerConnectionViewModel.swift */,
|
||||
E1ED7FE12CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift */,
|
||||
E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */,
|
||||
4EC2B1A42CC96F9F00D866BE /* ServerUserAdminViewModel.swift */,
|
||||
4EC2B1A12CC96F6000D866BE /* ServerUsersViewModel.swift */,
|
||||
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */,
|
||||
E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */,
|
||||
E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */,
|
||||
@ -4724,6 +4783,7 @@
|
||||
E1575E66293E77B5001665B1 /* Poster.swift in Sources */,
|
||||
E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */,
|
||||
E19D41B42BF2C0020082B8B2 /* StoredValues+Temp.swift in Sources */,
|
||||
4EC2B1A32CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */,
|
||||
4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */,
|
||||
E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */,
|
||||
4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */,
|
||||
@ -4735,6 +4795,8 @@
|
||||
E1549678296CB22B00C4EF88 /* InlineEnumToggle.swift in Sources */,
|
||||
E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */,
|
||||
E1DABAFA2A270E62008AC34A /* OverviewCard.swift in Sources */,
|
||||
E101ECD62CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */,
|
||||
4EA397462CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */,
|
||||
E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */,
|
||||
4E2AC4CC2C6C494E00DD600D /* VideoCodec.swift in Sources */,
|
||||
4EF18B262CB9934C00343666 /* LibraryRow.swift in Sources */,
|
||||
@ -4889,6 +4951,7 @@
|
||||
E1CB75732C80E71800217C76 /* DirectPlayProfile.swift in Sources */,
|
||||
E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */,
|
||||
6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */,
|
||||
4EC2B1A22CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */,
|
||||
E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */,
|
||||
E18E01DB288747230022598C /* iPadOSEpisodeItemView.swift in Sources */,
|
||||
E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */,
|
||||
@ -4918,6 +4981,7 @@
|
||||
4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */,
|
||||
E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */,
|
||||
E1AEFA372BE317E200CFAFD8 /* ListRowButton.swift in Sources */,
|
||||
4EB7C8D52CCED6E7000CC011 /* AddServerUserView.swift in Sources */,
|
||||
E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
|
||||
E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */,
|
||||
E1803EA12BFBD6CF0039F90E /* Hashable.swift in Sources */,
|
||||
@ -4969,6 +5033,7 @@
|
||||
C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */,
|
||||
E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */,
|
||||
BD39577E2C1140810078CEF8 /* TransitionSection.swift in Sources */,
|
||||
4EC2B1A52CC96FA400D866BE /* ServerUserAdminViewModel.swift in Sources */,
|
||||
E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */,
|
||||
E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */,
|
||||
E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */,
|
||||
@ -5090,6 +5155,7 @@
|
||||
E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */,
|
||||
4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */,
|
||||
E168BD10289A4162001A6922 /* HomeView.swift in Sources */,
|
||||
4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */,
|
||||
E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */,
|
||||
4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */,
|
||||
E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */,
|
||||
@ -5136,6 +5202,7 @@
|
||||
4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */,
|
||||
E1A1528528FD191A00600579 /* TextPair.swift in Sources */,
|
||||
6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */,
|
||||
4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */,
|
||||
4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */,
|
||||
4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */,
|
||||
E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */,
|
||||
@ -5144,6 +5211,7 @@
|
||||
E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
|
||||
E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
|
||||
E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */,
|
||||
4EC2B19E2CC96EAB00D866BE /* ServerUsersRow.swift in Sources */,
|
||||
E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */,
|
||||
C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */,
|
||||
4E17498E2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */,
|
||||
@ -5178,6 +5246,7 @@
|
||||
E1921B7628E63306003A5238 /* GestureView.swift in Sources */,
|
||||
E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */,
|
||||
E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */,
|
||||
E101ECD52CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */,
|
||||
E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */,
|
||||
4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */,
|
||||
4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */,
|
||||
@ -5314,6 +5383,7 @@
|
||||
E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */,
|
||||
E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */,
|
||||
E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */,
|
||||
4EC2B19B2CC96E7400D866BE /* ServerUsersView.swift in Sources */,
|
||||
E18E01F1288747230022598C /* PlayButton.swift in Sources */,
|
||||
E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */,
|
||||
E1F5CF092CB0A04500607465 /* Text.swift in Sources */,
|
||||
|
@ -12,41 +12,29 @@ import SwiftUI
|
||||
extension ButtonStyle where Self == ToolbarPillButtonStyle {
|
||||
|
||||
static var toolbarPill: ToolbarPillButtonStyle {
|
||||
ToolbarPillButtonStyle()
|
||||
ToolbarPillButtonStyle(primary: Defaults[.accentColor], secondary: .secondary)
|
||||
}
|
||||
|
||||
static func toolbarPill(_ primary: Color, _ secondary: Color = Color.secondary) -> ToolbarPillButtonStyle {
|
||||
ToolbarPillButtonStyle(primary: primary, secondary: secondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolbarPillButtonStyle: ButtonStyle {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@Environment(\.isEnabled)
|
||||
private var isEnabled
|
||||
|
||||
private var foregroundStyle: some ShapeStyle {
|
||||
if isEnabled {
|
||||
accentColor.overlayColor
|
||||
} else {
|
||||
Color.secondary.overlayColor
|
||||
}
|
||||
}
|
||||
|
||||
private var background: some ShapeStyle {
|
||||
if isEnabled {
|
||||
accentColor
|
||||
} else {
|
||||
Color.secondary
|
||||
}
|
||||
}
|
||||
let primary: Color
|
||||
let secondary: Color
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundStyle(foregroundStyle)
|
||||
.foregroundStyle(isEnabled ? primary.overlayColor : secondary)
|
||||
.font(.headline)
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 10)
|
||||
.background(background)
|
||||
.background(isEnabled ? primary : secondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.opacity(isEnabled && !configuration.isPressed ? 1 : 0.5)
|
||||
}
|
||||
|
@ -25,10 +25,14 @@ struct ActiveSessionDetailView: View {
|
||||
private func idleContent(session: SessionInfo) -> some View {
|
||||
List {
|
||||
if let userID = session.userID {
|
||||
let user = UserDto(id: userID, name: session.userName)
|
||||
|
||||
UserDashboardView.UserSection(
|
||||
user: .init(id: userID, name: session.userName),
|
||||
user: user,
|
||||
lastActivityDate: session.lastActivityDate
|
||||
)
|
||||
) {
|
||||
router.route(to: \.userDetails, user)
|
||||
}
|
||||
}
|
||||
|
||||
UserDashboardView.DeviceSection(
|
||||
@ -60,9 +64,14 @@ struct ActiveSessionDetailView: View {
|
||||
}
|
||||
|
||||
if let userID = session.userID {
|
||||
let user = UserDto(id: userID, name: session.userName)
|
||||
|
||||
UserDashboardView.UserSection(
|
||||
user: .init(id: userID, name: session.userName)
|
||||
)
|
||||
user: user,
|
||||
lastActivityDate: session.lastPlaybackCheckIn
|
||||
) {
|
||||
router.route(to: \.userDetails, user)
|
||||
}
|
||||
}
|
||||
|
||||
UserDashboardView.DeviceSection(
|
||||
|
@ -112,7 +112,7 @@ extension ActiveSessionsView {
|
||||
if let lastActivityDate = session.lastActivityDate {
|
||||
TextPairView(
|
||||
L10n.lastSeen,
|
||||
value: Text(lastActivityDate, format: .relative(presentation: .numeric, unitsStyle: .narrow))
|
||||
value: Text(lastActivityDate, format: .lastSeen)
|
||||
)
|
||||
.id(currentDate)
|
||||
.monospacedDigit()
|
||||
|
@ -0,0 +1,152 @@
|
||||
//
|
||||
// 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 Combine
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerUserView: View {
|
||||
|
||||
private enum Field: Hashable {
|
||||
case username
|
||||
case password
|
||||
case confirmPassword
|
||||
}
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: BasicNavigationViewCoordinator.Router
|
||||
|
||||
@FocusState
|
||||
private var focusedfield: Field?
|
||||
|
||||
@State
|
||||
private var username: String = ""
|
||||
@State
|
||||
private var password: String = ""
|
||||
@State
|
||||
private var confirmPassword: String = ""
|
||||
|
||||
@State
|
||||
private var error: Error?
|
||||
@State
|
||||
private var isPresentingError: Bool = false
|
||||
@State
|
||||
private var isPresentingSuccess: Bool = false
|
||||
|
||||
@StateObject
|
||||
private var viewModel = AddServerUserViewModel()
|
||||
|
||||
private var isValid: Bool {
|
||||
username.isNotEmpty && password == confirmPassword
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
|
||||
Section {
|
||||
TextField(L10n.username, text: $username) {
|
||||
focusedfield = .password
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.none)
|
||||
.focused($focusedfield, equals: .username)
|
||||
.disabled(viewModel.state == .creatingUser)
|
||||
} header: {
|
||||
Text(L10n.username)
|
||||
} footer: {
|
||||
if username.isEmpty {
|
||||
Label(L10n.usernameRequired, systemImage: "exclamationmark.circle.fill")
|
||||
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
|
||||
}
|
||||
}
|
||||
|
||||
Section(L10n.password) {
|
||||
UnmaskSecureField(L10n.password, text: $password) {
|
||||
focusedfield = .confirmPassword
|
||||
}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.none)
|
||||
.focused($focusedfield, equals: .password)
|
||||
.disabled(viewModel.state == .creatingUser)
|
||||
}
|
||||
|
||||
Section {
|
||||
UnmaskSecureField(L10n.confirmPassword, text: $confirmPassword) {}
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.none)
|
||||
.focused($focusedfield, equals: .confirmPassword)
|
||||
.disabled(viewModel.state == .creatingUser)
|
||||
} header: {
|
||||
Text(L10n.confirmPassword)
|
||||
} footer: {
|
||||
if password != confirmPassword {
|
||||
Label(L10n.passwordsDoNotMatch, systemImage: "exclamationmark.circle.fill")
|
||||
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: isValid)
|
||||
.interactiveDismissDisabled(viewModel.state == .creatingUser)
|
||||
.navigationTitle(L10n.newUser)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarCloseButton {
|
||||
router.dismissCoordinator()
|
||||
}
|
||||
.onFirstAppear {
|
||||
focusedfield = .username
|
||||
}
|
||||
.onReceive(viewModel.events) { event in
|
||||
switch event {
|
||||
case let .error(eventError):
|
||||
UIDevice.feedback(.error)
|
||||
|
||||
error = eventError
|
||||
isPresentingError = true
|
||||
case let .createdNewUser(newUser):
|
||||
UIDevice.feedback(.success)
|
||||
|
||||
router.dismissCoordinator {
|
||||
Notifications[.didAddServerUser].post(object: newUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
.topBarTrailing {
|
||||
if viewModel.state == .creatingUser {
|
||||
ProgressView()
|
||||
}
|
||||
|
||||
if viewModel.state == .creatingUser {
|
||||
Button(L10n.cancel) {
|
||||
viewModel.send(.cancel)
|
||||
}
|
||||
.buttonStyle(.toolbarPill(.red))
|
||||
} else {
|
||||
Button(L10n.save) {
|
||||
viewModel.send(.createUser(username: username, password: password))
|
||||
}
|
||||
.buttonStyle(.toolbarPill)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
L10n.error,
|
||||
isPresented: $isPresentingError,
|
||||
presenting: error
|
||||
) { _ in
|
||||
Button(L10n.dismiss, role: .cancel) {
|
||||
focusedfield = .username
|
||||
}
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
@ -9,8 +9,6 @@
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
// TODO: if lastActivityDate not in same day, use date instead of relative
|
||||
|
||||
extension UserDashboardView {
|
||||
|
||||
struct UserSection: View {
|
||||
@ -20,26 +18,43 @@ extension UserDashboardView {
|
||||
|
||||
private let user: UserDto
|
||||
private let lastActivityDate: Date?
|
||||
private let action: (() -> Void)?
|
||||
|
||||
init(user: UserDto, lastActivityDate: Date? = nil) {
|
||||
// MARK: - Initializer
|
||||
|
||||
init(user: UserDto, lastActivityDate: Date? = nil, action: (() -> Void)? = nil) {
|
||||
self.user = user
|
||||
self.lastActivityDate = lastActivityDate
|
||||
self.action = action
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Section(L10n.user) {
|
||||
profileView
|
||||
TextPairView(
|
||||
L10n.lastSeen,
|
||||
value: Text(lastActivityDate, format: .lastSeen)
|
||||
)
|
||||
.id(currentDate)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profile View
|
||||
|
||||
private var profileView: some View {
|
||||
if let onSelect = action {
|
||||
SettingsView.UserProfileRow(
|
||||
user: user
|
||||
) {
|
||||
onSelect()
|
||||
}
|
||||
} else {
|
||||
SettingsView.UserProfileRow(
|
||||
user: user
|
||||
)
|
||||
|
||||
if let lastActivityDate {
|
||||
TextPairView(
|
||||
L10n.lastSeen,
|
||||
value: Text(lastActivityDate, format: .relative(presentation: .numeric, unitsStyle: .narrow))
|
||||
)
|
||||
.id(currentDate)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,9 @@ import SwiftUI
|
||||
|
||||
struct DeviceDetailsView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: SettingsCoordinator.Router
|
||||
|
||||
@CurrentDate
|
||||
private var currentDate: Date
|
||||
|
||||
@ -27,28 +30,39 @@ struct DeviceDetailsView: View {
|
||||
private var isPresentingSuccess: Bool = false
|
||||
|
||||
@StateObject
|
||||
private var viewModel: DevicesViewModel
|
||||
private var viewModel: DeviceDetailViewModel
|
||||
|
||||
private let device: DeviceInfo
|
||||
private var device: DeviceInfo {
|
||||
viewModel.device
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(device: DeviceInfo) {
|
||||
self.device = device
|
||||
_viewModel = StateObject(wrappedValue: DeviceDetailViewModel(device: device))
|
||||
|
||||
// TODO: Enable with SDK Change
|
||||
self.temporaryCustomName = device.name ?? "" // device.customName ?? device.name
|
||||
_viewModel = StateObject(wrappedValue: DevicesViewModel(device.lastUserID))
|
||||
|
||||
// _viewModel = StateObject(wrappedValue: DevicesViewModel(device.lastUserID))
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let lastUserID = device.lastUserID {
|
||||
if let userID = device.lastUserID,
|
||||
let userName = device.lastUserName
|
||||
{
|
||||
|
||||
let user = UserDto(id: userID, name: userName)
|
||||
|
||||
UserDashboardView.UserSection(
|
||||
user: .init(id: lastUserID, name: device.lastUserName),
|
||||
user: user,
|
||||
lastActivityDate: device.dateLastActivity
|
||||
)
|
||||
) {
|
||||
router.route(to: \.userDetails, user)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Enable with SDK Change
|
||||
@ -69,13 +83,13 @@ struct DeviceDetailsView: View {
|
||||
UIDevice.feedback(.error)
|
||||
error = eventError
|
||||
isPresentingError = true
|
||||
case .success:
|
||||
case .setCustomName:
|
||||
UIDevice.feedback(.success)
|
||||
isPresentingSuccess = true
|
||||
}
|
||||
}
|
||||
.topBarTrailing {
|
||||
if viewModel.backgroundStates.contains(.settingCustomName) {
|
||||
if viewModel.backgroundStates.contains(.updating) {
|
||||
ProgressView()
|
||||
|
||||
// TODO: Enable with SDK Change
|
||||
|
@ -32,31 +32,9 @@ extension DevicesView {
|
||||
|
||||
// MARK: - Observed Objects
|
||||
|
||||
@ObservedObject
|
||||
private var box: BindingBox<DeviceInfo?>
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private let onSelect: () -> Void
|
||||
private let onDelete: () -> Void
|
||||
|
||||
// MARK: - Device Mapping
|
||||
|
||||
private var deviceInfo: DeviceInfo {
|
||||
box.value ?? .init()
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(
|
||||
box: BindingBox<DeviceInfo?>,
|
||||
onSelect: @escaping () -> Void,
|
||||
onDelete: @escaping () -> Void
|
||||
) {
|
||||
self.box = box
|
||||
self.onSelect = onSelect
|
||||
self.onDelete = onDelete
|
||||
}
|
||||
let device: DeviceInfo
|
||||
let onSelect: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
// MARK: - Label Styling
|
||||
|
||||
@ -71,9 +49,9 @@ extension DevicesView {
|
||||
@ViewBuilder
|
||||
private var deviceImage: some View {
|
||||
ZStack {
|
||||
deviceInfo.device.clientColor
|
||||
device.type.clientColor
|
||||
|
||||
Image(deviceInfo.device.image)
|
||||
Image(device.type.image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 40)
|
||||
@ -95,30 +73,24 @@ extension DevicesView {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
Text(deviceInfo.name ?? L10n.unknown)
|
||||
Text(device.name ?? L10n.unknown)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
TextPairView(
|
||||
leading: L10n.user,
|
||||
trailing: deviceInfo.lastUserName ?? L10n.unknown
|
||||
trailing: device.lastUserName ?? L10n.unknown
|
||||
)
|
||||
|
||||
TextPairView(
|
||||
leading: L10n.client,
|
||||
trailing: deviceInfo.appName ?? L10n.unknown
|
||||
trailing: device.appName ?? L10n.unknown
|
||||
)
|
||||
|
||||
TextPairView(
|
||||
L10n.lastSeen,
|
||||
value: {
|
||||
if let dateLastActivity = deviceInfo.dateLastActivity {
|
||||
Text(dateLastActivity, format: .relative(presentation: .numeric, unitsStyle: .narrow))
|
||||
} else {
|
||||
Text(L10n.never)
|
||||
}
|
||||
}()
|
||||
value: Text(device.dateLastActivity, format: .lastSeen)
|
||||
)
|
||||
.id(currentDate)
|
||||
.monospacedDigit()
|
||||
|
@ -8,6 +8,7 @@
|
||||
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import OrderedCollections
|
||||
import SwiftUI
|
||||
|
||||
// TODO: Replace with CustomName when Available
|
||||
@ -29,13 +30,7 @@ struct DevicesView: View {
|
||||
private var isEditing: Bool = false
|
||||
|
||||
@StateObject
|
||||
private var viewModel: DevicesViewModel
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(userID: String? = nil) {
|
||||
_viewModel = StateObject(wrappedValue: DevicesViewModel(userID))
|
||||
}
|
||||
private var viewModel = DevicesViewModel()
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
@ -43,11 +38,7 @@ struct DevicesView: View {
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case .content:
|
||||
if viewModel.devices.isEmpty {
|
||||
Text(L10n.none)
|
||||
} else {
|
||||
deviceListView
|
||||
}
|
||||
deviceListView
|
||||
case let .error(error):
|
||||
ErrorView(error: error)
|
||||
.onRetry {
|
||||
@ -68,7 +59,19 @@ struct DevicesView: View {
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
navigationBarEditView
|
||||
if viewModel.devices.isNotEmpty {
|
||||
navigationBarEditView
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
if isEditing {
|
||||
Button(L10n.delete) {
|
||||
isPresentingDeleteSelectionConfirmation = true
|
||||
}
|
||||
.buttonStyle(.toolbarPill(.red))
|
||||
.disabled(selectedDevices.isEmpty)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFirstAppear {
|
||||
@ -103,76 +106,51 @@ struct DevicesView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var deviceListView: some View {
|
||||
VStack {
|
||||
List {
|
||||
InsetGroupedListHeader(
|
||||
L10n.devices,
|
||||
description: L10n.allDevicesDescription
|
||||
) {
|
||||
UIApplication.shared.open(.jellyfinDocsDevices)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.vertical, 24)
|
||||
List {
|
||||
InsetGroupedListHeader(
|
||||
L10n.devices,
|
||||
description: L10n.allDevicesDescription
|
||||
) {
|
||||
UIApplication.shared.open(.jellyfinDocsDevices)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.vertical, 24)
|
||||
|
||||
ForEach(viewModel.devices.keys, id: \.self) { id in
|
||||
if let deviceBox = viewModel.devices[id] {
|
||||
DeviceRow(box: deviceBox) {
|
||||
if isEditing {
|
||||
if selectedDevices.contains(id) {
|
||||
selectedDevices.remove(id)
|
||||
} else {
|
||||
selectedDevices.insert(id)
|
||||
}
|
||||
} else if let selectedDevice = deviceBox.value {
|
||||
router.route(to: \.deviceDetails, selectedDevice)
|
||||
if viewModel.devices.isEmpty {
|
||||
Text(L10n.none)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(.zero)
|
||||
} else {
|
||||
ForEach(viewModel.devices, id: \.self) { device in
|
||||
DeviceRow(device: device) {
|
||||
guard let id = device.id else { return }
|
||||
|
||||
if isEditing {
|
||||
if selectedDevices.contains(id) {
|
||||
selectedDevices.remove(id)
|
||||
} else {
|
||||
selectedDevices.insert(id)
|
||||
}
|
||||
} onDelete: {
|
||||
selectedDevices.removeAll()
|
||||
selectedDevices.insert(id)
|
||||
isPresentingDeleteConfirmation = true
|
||||
} else {
|
||||
router.route(to: \.deviceDetails, device)
|
||||
}
|
||||
.environment(\.isEditing, isEditing)
|
||||
.environment(\.isSelected, selectedDevices.contains(id))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(.zero)
|
||||
} onDelete: {
|
||||
guard let id = device.id else { return }
|
||||
|
||||
selectedDevices.removeAll()
|
||||
selectedDevices.insert(id)
|
||||
isPresentingDeleteConfirmation = true
|
||||
}
|
||||
.environment(\.isEditing, isEditing)
|
||||
.environment(\.isSelected, selectedDevices.contains(device.id ?? ""))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(.zero)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
|
||||
if isEditing {
|
||||
deleteDevicesButton
|
||||
.edgePadding([.bottom, .horizontal])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Button to Delete Devices
|
||||
|
||||
@ViewBuilder
|
||||
private var deleteDevicesButton: some View {
|
||||
Button {
|
||||
isPresentingDeleteSelectionConfirmation = true
|
||||
} label: {
|
||||
ZStack {
|
||||
Color.red
|
||||
|
||||
Text(L10n.delete)
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(selectedDevices.isNotEmpty ? .primary : .secondary)
|
||||
|
||||
if selectedDevices.isEmpty {
|
||||
Color.black
|
||||
.opacity(0.5)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.frame(height: 50)
|
||||
.frame(maxWidth: 400)
|
||||
}
|
||||
.disabled(selectedDevices.isEmpty)
|
||||
.buttonStyle(.plain)
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Navigation Bar Edit Content
|
||||
@ -203,7 +181,7 @@ struct DevicesView: View {
|
||||
if isAllSelected {
|
||||
selectedDevices = []
|
||||
} else {
|
||||
selectedDevices = Set(viewModel.devices.keys)
|
||||
selectedDevices = Set(viewModel.devices.compactMap(\.id))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.toolbarPill)
|
||||
|
@ -29,7 +29,7 @@ extension EditServerTaskView {
|
||||
|
||||
TextPairView(
|
||||
L10n.executed,
|
||||
value: Text("\(endTime, format: .relative(presentation: .numeric, unitsStyle: .narrow))")
|
||||
value: Text(endTime, format: .lastSeen)
|
||||
)
|
||||
.id(currentDate)
|
||||
.monospacedDigit()
|
||||
|
@ -0,0 +1,44 @@
|
||||
//
|
||||
// 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 ServerUserDetailsView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: SettingsCoordinator.Router
|
||||
|
||||
@CurrentDate
|
||||
private var currentDate: Date
|
||||
|
||||
@StateObject
|
||||
private var viewModel: ServerUserAdminViewModel
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(user: UserDto) {
|
||||
_viewModel = StateObject(wrappedValue: ServerUserAdminViewModel(user: user))
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
UserDashboardView.UserSection(
|
||||
user: viewModel.user,
|
||||
lastActivityDate: viewModel.user.lastActivityDate
|
||||
)
|
||||
}
|
||||
.navigationTitle(L10n.user)
|
||||
.onAppear {
|
||||
viewModel.send(.loadDetails)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,178 @@
|
||||
//
|
||||
// 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 Factory
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension ServerUsersView {
|
||||
|
||||
struct ServerUsersRow: View {
|
||||
|
||||
@Injected(\.currentUserSession)
|
||||
private var userSession
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
// MARK: - Environment Variables
|
||||
|
||||
@Environment(\.colorScheme)
|
||||
private var colorScheme
|
||||
@Environment(\.isEditing)
|
||||
private var isEditing
|
||||
@Environment(\.isSelected)
|
||||
private var isSelected
|
||||
|
||||
@CurrentDate
|
||||
private var currentDate: Date
|
||||
|
||||
private let user: UserDto
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private let onSelect: () -> Void
|
||||
private let onDelete: () -> Void
|
||||
|
||||
// MARK: - User Status Mapping
|
||||
|
||||
private var userActive: Bool {
|
||||
if let isDisabled = user.policy?.isDisabled {
|
||||
return !isDisabled
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(
|
||||
user: UserDto,
|
||||
onSelect: @escaping () -> Void,
|
||||
onDelete: @escaping () -> Void
|
||||
) {
|
||||
self.user = user
|
||||
self.onSelect = onSelect
|
||||
self.onDelete = onDelete
|
||||
}
|
||||
|
||||
// MARK: - Label Styling
|
||||
|
||||
private var labelForegroundStyle: some ShapeStyle {
|
||||
guard isEditing else { return userActive ? .primary : .secondary }
|
||||
|
||||
return isSelected ? .primary : .secondary
|
||||
}
|
||||
|
||||
// MARK: - User Image View
|
||||
|
||||
@ViewBuilder
|
||||
private var userImage: some View {
|
||||
ZStack {
|
||||
ImageView(user.profileImageSource(client: userSession!.client))
|
||||
.pipeline(.Swiftfin.branding)
|
||||
.placeholder { _ in
|
||||
SystemImageContentView(systemName: "person.fill", ratio: 0.5)
|
||||
}
|
||||
.failure {
|
||||
SystemImageContentView(systemName: "person.fill", ratio: 0.5)
|
||||
}
|
||||
.grayscale(userActive ? 0.0 : 1.0)
|
||||
|
||||
if isEditing {
|
||||
Color.black
|
||||
.opacity(isSelected ? 0 : 0.5)
|
||||
}
|
||||
}
|
||||
.clipShape(.circle)
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.posterShadow()
|
||||
.frame(width: 60, height: 60)
|
||||
}
|
||||
|
||||
// MARK: - Row Content
|
||||
|
||||
@ViewBuilder
|
||||
private var rowContent: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
|
||||
Text(user.name ?? L10n.unknown)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
TextPairView(
|
||||
L10n.role,
|
||||
value: {
|
||||
if let isAdministrator = user.policy?.isAdministrator,
|
||||
isAdministrator
|
||||
{
|
||||
Text(L10n.administrator)
|
||||
} else {
|
||||
Text(L10n.user)
|
||||
}
|
||||
}()
|
||||
)
|
||||
|
||||
TextPairView(
|
||||
L10n.lastSeen,
|
||||
value: Text(user.lastActivityDate, format: .lastSeen)
|
||||
)
|
||||
.id(currentDate)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(labelForegroundStyle, .secondary)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) {
|
||||
userImage
|
||||
} content: {
|
||||
rowContent
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.onSelect(perform: onSelect)
|
||||
.swipeActions {
|
||||
Button(
|
||||
L10n.delete,
|
||||
systemImage: "trash",
|
||||
action: onDelete
|
||||
)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,272 @@
|
||||
//
|
||||
// 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 CollectionVGrid
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct ServerUsersView: View {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: SettingsCoordinator.Router
|
||||
|
||||
@State
|
||||
private var isPresentingDeleteSelectionConfirmation = false
|
||||
@State
|
||||
private var isPresentingDeleteConfirmation = false
|
||||
@State
|
||||
private var isPresentingSelfDeleteError = false
|
||||
@State
|
||||
private var selectedUsers: Set<String> = []
|
||||
@State
|
||||
private var isEditing: Bool = false
|
||||
|
||||
@State
|
||||
private var isHiddenFilterActive: Bool = false
|
||||
@State
|
||||
private var isDisabledFilterActive: Bool = false
|
||||
|
||||
@StateObject
|
||||
private var viewModel = ServerUsersViewModel()
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case .content:
|
||||
userListView
|
||||
case let .error(error):
|
||||
errorView(with: error)
|
||||
case .initial:
|
||||
DelayedProgressView()
|
||||
}
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: viewModel.state)
|
||||
.navigationTitle(L10n.users)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(isEditing)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if isEditing {
|
||||
navigationBarSelectView
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
navigationBarEditView
|
||||
}
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
if isEditing {
|
||||
Button(L10n.delete) {
|
||||
isPresentingDeleteSelectionConfirmation = true
|
||||
}
|
||||
.buttonStyle(.toolbarPill(.red))
|
||||
.disabled(selectedUsers.isEmpty)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: isDisabledFilterActive) { newValue in
|
||||
viewModel.send(.getUsers(
|
||||
isHidden: isHiddenFilterActive,
|
||||
isDisabled: newValue
|
||||
))
|
||||
}
|
||||
.onChange(of: isHiddenFilterActive) { newValue in
|
||||
viewModel.send(.getUsers(
|
||||
isHidden: newValue,
|
||||
isDisabled: isDisabledFilterActive
|
||||
))
|
||||
}
|
||||
.onFirstAppear {
|
||||
viewModel.send(.getUsers())
|
||||
}
|
||||
.confirmationDialog(
|
||||
L10n.deleteSelectedUsers,
|
||||
isPresented: $isPresentingDeleteSelectionConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
deleteSelectedUsersConfirmationActions
|
||||
} message: {
|
||||
Text(L10n.deleteSelectionUsersWarning)
|
||||
}
|
||||
.confirmationDialog(
|
||||
L10n.deleteUser,
|
||||
isPresented: $isPresentingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
deleteUserConfirmationActions
|
||||
} message: {
|
||||
Text(L10n.deleteUserWarning)
|
||||
}
|
||||
.alert(L10n.deleteUserFailed, isPresented: $isPresentingSelfDeleteError) {
|
||||
Button(L10n.ok, role: .cancel) {}
|
||||
} message: {
|
||||
Text(L10n.deleteUserSelfDeletion(viewModel.userSession.user.username))
|
||||
}
|
||||
.onNotification(.didAddServerUser) { notification in
|
||||
let newUser = notification.object as! UserDto
|
||||
viewModel.send(.appendUser(newUser))
|
||||
router.route(to: \.userDetails, newUser)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User List View
|
||||
|
||||
@ViewBuilder
|
||||
private var userListView: some View {
|
||||
List {
|
||||
InsetGroupedListHeader(
|
||||
L10n.users,
|
||||
description: L10n.allUsersDescription
|
||||
) {
|
||||
UIApplication.shared.open(.jellyfinDocsUsers)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.vertical, 24)
|
||||
|
||||
if viewModel.users.isEmpty {
|
||||
Text(L10n.none)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(.zero)
|
||||
} else {
|
||||
ForEach(viewModel.users, id: \.self) { user in
|
||||
if let userID = user.id {
|
||||
ServerUsersRow(user: user) {
|
||||
if isEditing {
|
||||
selectedUsers.toggle(value: userID)
|
||||
} else {
|
||||
router.route(to: \.userDetails, user)
|
||||
}
|
||||
} onDelete: {
|
||||
selectedUsers.removeAll()
|
||||
selectedUsers.insert(userID)
|
||||
isPresentingDeleteConfirmation = true
|
||||
}
|
||||
.environment(\.isEditing, isEditing)
|
||||
.environment(\.isSelected, selectedUsers.contains(userID))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(.zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Error View
|
||||
|
||||
@ViewBuilder
|
||||
private func errorView(with error: some Error) -> some View {
|
||||
ErrorView(error: error)
|
||||
.onRetry {
|
||||
viewModel.send(.getUsers(isHidden: isHiddenFilterActive, isDisabled: isDisabledFilterActive))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation Bar Edit Content
|
||||
|
||||
@ViewBuilder
|
||||
private var navigationBarEditView: some View {
|
||||
if viewModel.backgroundStates.contains(.gettingUsers) {
|
||||
ProgressView()
|
||||
}
|
||||
|
||||
if isEditing {
|
||||
Button(isEditing ? L10n.cancel : L10n.edit) {
|
||||
isEditing.toggle()
|
||||
|
||||
UIDevice.impact(.light)
|
||||
|
||||
if !isEditing {
|
||||
selectedUsers.removeAll()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.toolbarPill)
|
||||
.foregroundStyle(accentColor)
|
||||
} else {
|
||||
Menu(L10n.options, systemImage: "ellipsis.circle") {
|
||||
Button(L10n.addUser, systemImage: "plus") {
|
||||
router.route(to: \.addServerUser)
|
||||
}
|
||||
|
||||
if viewModel.users.isNotEmpty {
|
||||
Button(L10n.editUsers, systemImage: "checkmark.circle") {
|
||||
isEditing = true
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Section(L10n.filters) {
|
||||
Toggle(L10n.hidden, systemImage: "eye.slash", isOn: $isHiddenFilterActive)
|
||||
Toggle(L10n.disabled, systemImage: "person.slash", isOn: $isDisabledFilterActive)
|
||||
}
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.backport
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation Bar Select/Remove All Content
|
||||
|
||||
@ViewBuilder
|
||||
private var navigationBarSelectView: some View {
|
||||
|
||||
let isAllSelected: Bool = selectedUsers.count == viewModel.users.count
|
||||
|
||||
Button(isAllSelected ? L10n.removeAll : L10n.selectAll) {
|
||||
if isAllSelected {
|
||||
selectedUsers = []
|
||||
} else {
|
||||
selectedUsers = Set(viewModel.users.compactMap(\.id))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.toolbarPill)
|
||||
.disabled(!isEditing)
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
|
||||
// MARK: - Delete Selected Users Confirmation Actions
|
||||
|
||||
@ViewBuilder
|
||||
private var deleteSelectedUsersConfirmationActions: some View {
|
||||
Button(L10n.cancel, role: .cancel) {}
|
||||
|
||||
Button(L10n.confirm, role: .destructive) {
|
||||
viewModel.send(.deleteUsers(Array(selectedUsers)))
|
||||
isEditing = false
|
||||
selectedUsers.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete User Confirmation Actions
|
||||
|
||||
@ViewBuilder
|
||||
private var deleteUserConfirmationActions: some View {
|
||||
Button(L10n.cancel, role: .cancel) {}
|
||||
|
||||
Button(L10n.delete, role: .destructive) {
|
||||
if let userToDelete = selectedUsers.first, selectedUsers.count == 1 {
|
||||
if userToDelete == viewModel.userSession.user.id {
|
||||
isPresentingSelfDeleteError = true
|
||||
} else {
|
||||
viewModel.send(.deleteUsers([userToDelete]))
|
||||
selectedUsers.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -28,11 +28,15 @@ struct UserDashboardView: View {
|
||||
router.route(to: \.activeSessions)
|
||||
}
|
||||
|
||||
Section("Activity") {
|
||||
Section(L10n.activity) {
|
||||
ChevronButton(L10n.devices)
|
||||
.onSelect {
|
||||
router.route(to: \.devices)
|
||||
}
|
||||
ChevronButton(L10n.users)
|
||||
.onSelect {
|
||||
router.route(to: \.users)
|
||||
}
|
||||
}
|
||||
|
||||
Section(L10n.advanced) {
|
||||
|
@ -920,6 +920,41 @@
|
||||
// Used to select all items in selection mode
|
||||
"selectAll" = "Select All";
|
||||
|
||||
// Users - Section
|
||||
// Admin Dashboard Section with all Server Users
|
||||
// Used as a header and a button for All Users
|
||||
"users" = "Users";
|
||||
|
||||
// Active - Label
|
||||
// Indication whether an item is active or inactive
|
||||
// Used as a User describer and a button for All Users
|
||||
"active" = "Active";
|
||||
|
||||
// All Users Description - Section Description
|
||||
// Description for the all users section in the Admin Dashboard
|
||||
// Provides information about the users on the server
|
||||
"allUsersDescription" = "View and manage all registered users on the server, including their permissions and activity status.";
|
||||
|
||||
// Role - Label
|
||||
// Represents the role of the user
|
||||
// Shown in user information
|
||||
"role" = "Role";
|
||||
|
||||
// Administrator - Title
|
||||
// Label for administrator role
|
||||
// Indicates the user is an admin
|
||||
"administrator" = "Administrator";
|
||||
|
||||
// User - Title
|
||||
// Label for non-administrator users
|
||||
// Indicates the user is a standard user
|
||||
"user" = "User";
|
||||
|
||||
// Activity - Label
|
||||
// Represents user activity status
|
||||
// Shown in user information
|
||||
"activity" = "Activity";
|
||||
|
||||
// Logs Description - View
|
||||
// Access the Jellyfin server logs for troubleshooting and monitoring purposes
|
||||
// Describes the logs view in settings
|
||||
@ -1098,9 +1133,83 @@
|
||||
// Appears in the views with eventful to indicate a task did not fail
|
||||
"success" = "Success";
|
||||
|
||||
|
||||
// Trigger Already Exists -
|
||||
// Message to indicate that a Task Trigger already exists
|
||||
// Appears in AddServerTask when there is an existing task with the same configuration
|
||||
"triggerAlreadyExists" = "Trigger already exists";
|
||||
|
||||
// Add API Key - Button
|
||||
// Creates an API Key if there are no keys available
|
||||
// Appears in place of the API Key list if there are no API Keys
|
||||
"addAPIKey" = "Add API key";
|
||||
|
||||
// Hidden - Filter
|
||||
// Users with a policy of isHidden == True
|
||||
// Appears on the ServerUsersView to filter Hidden vs Non-Hidden Users
|
||||
"hidden" = "Hidden";
|
||||
|
||||
// Delete Selected Users Warning - Warning Message
|
||||
// Warning message displayed when deleting all users
|
||||
// Informs the user about the consequences of deleting all users
|
||||
"deleteSelectionUsersWarning" = "Are you sure you wish to delete all selected users?";
|
||||
|
||||
// Delete User Warning - Warning Message
|
||||
// Warning message displayed when deleting a single user
|
||||
// Informs the user about the consequences of deleting the user
|
||||
"deleteUserWarning" = "Are you sure you wish to delete this user?";
|
||||
|
||||
// Delete User - Action
|
||||
// Message for deleting a single device in the all users section
|
||||
// Used in the confirmation dialog to delete a single user
|
||||
"deleteUser" = "Delete User";
|
||||
|
||||
// Delete User Self-Deletion - Error Message
|
||||
// Error message when attempting to delete the current session's user
|
||||
// Used to inform the user that they cannot delete their own user
|
||||
"deleteUserSelfDeletion" = "Cannot delete a user from the same user (%1$@).";
|
||||
|
||||
// Delete Selected Users - Button
|
||||
// Button label for deleting all selected users
|
||||
// Used in the all devices section to delete all selected users
|
||||
"deleteSelectedUsers" = "Delete Selected Users";
|
||||
|
||||
// Delete User Failed - Error Title
|
||||
// Title for the alert when users deletion fails
|
||||
// Displayed when the system fails to delete a user
|
||||
"deleteUserFailed" = "Failed to Delete User";
|
||||
|
||||
// Confirm Password - TextField
|
||||
// Placeholder and label for confirming the password
|
||||
// Used in the New User creation form
|
||||
"confirmPassword" = "Confirm Password";
|
||||
|
||||
// A username is required - Footer
|
||||
// Validation message shown when the username field is empty
|
||||
// Used in the New User creation form
|
||||
"usernameRequired" = "A username is required";
|
||||
|
||||
// New passwords do not match - Footer
|
||||
// Validation message shown when the new password and confirm password fields do not match
|
||||
// Used in the New User creation form
|
||||
"passwordsDoNotMatch" = "New passwords do not match";
|
||||
|
||||
// New User - Title
|
||||
// Title for the new user creation view
|
||||
// Used as the navigation title when creating a new user
|
||||
"newUser" = "New User";
|
||||
|
||||
// Options - Menu
|
||||
// Menu title for additional actions
|
||||
// Used as the label for the options menu in the navigation bar
|
||||
"options" = "Options";
|
||||
|
||||
// Add User - Button
|
||||
// Button title to add a new user
|
||||
// Used as the button label in the options menu
|
||||
"addUser" = "Add User";
|
||||
|
||||
// Edit Users - Button
|
||||
// Button title to edit existing users
|
||||
// Used as the button label in the options menu when there are users to edit
|
||||
"editUsers" = "Edit Users";
|
||||
|
Loading…
Reference in New Issue
Block a user