diff --git a/Shared/Coordinators/AdminDashboardCoordinator.swift b/Shared/Coordinators/AdminDashboardCoordinator.swift new file mode 100644 index 000000000..bcf548879 --- /dev/null +++ b/Shared/Coordinators/AdminDashboardCoordinator.swift @@ -0,0 +1,89 @@ +// +// 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 JellyfinAPI +import PulseUI +import Stinsen +import SwiftUI + +final class AdminDashboardCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \AdminDashboardCoordinator.start) + + @Root + var start = makeStart + + @Route(.push) + var activeSessions = makeActiveSessions + @Route(.push) + var activeDeviceDetails = makeActiveDeviceDetails + @Route(.push) + var devices = makeDevices + @Route(.push) + var tasks = makeTasks + @Route(.push) + var users = makeUsers + @Route(.push) + var userDetails = makeUserDetails + @Route(.push) + var editScheduledTask = makeEditScheduledTask + @Route(.modal) + var addScheduledTaskTrigger = makeAddScheduledTaskTrigger + @Route(.push) + var serverLogs = makeServerLogs + + @ViewBuilder + func makeActiveSessions() -> some View { + ActiveSessionsView() + } + + @ViewBuilder + func makeActiveDeviceDetails(box: BindingBox) -> some View { + ActiveSessionDetailView(box: box) + } + + @ViewBuilder + func makeDevices() -> some View { + DevicesView() + } + + @ViewBuilder + func makeTasks() -> some View { + ScheduledTasksView() + } + + @ViewBuilder + func makeUsers() -> some View { + UserAdministrationView() + } + + @ViewBuilder + func makeUserDetails(observer: UserAdministrationObserver) -> some View { + UserAdministrationDetailView(observer: observer) + } + + @ViewBuilder + func makeEditScheduledTask(observer: ServerTaskObserver) -> some View { + EditScheduledTaskView(observer: observer) + } + + func makeAddScheduledTaskTrigger(observer: ServerTaskObserver) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddTaskTriggerView(observer: observer) + } + } + + @ViewBuilder + func makeServerLogs() -> some View { + ServerLogsView() + } + + @ViewBuilder + func makeStart() -> some View { + AdminDashboardView() + } +} diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index 71c868c7f..a0a47e187 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -39,6 +39,8 @@ final class MainCoordinator: NavigationCoordinatable { var liveVideoPlayer = makeLiveVideoPlayer @Route(.modal) var settings = makeSettings + @Route(.modal) + var adminDashboard = makeAdminDashboard @Route(.fullScreen) var videoPlayer = makeVideoPlayer @@ -134,6 +136,10 @@ final class MainCoordinator: NavigationCoordinatable { NavigationViewCoordinator(SettingsCoordinator()) } + func makeAdminDashboard() -> NavigationViewCoordinator { + NavigationViewCoordinator(AdminDashboardCoordinator()) + } + func makeMainTab() -> MainTabCoordinator { MainTabCoordinator() } diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index c5d36cf46..44d27105c 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -34,7 +34,6 @@ final class SettingsCoordinator: NavigationCoordinatable { var photoPicker = makePhotoPicker @Route(.push) var userProfile = makeUserProfileSettings - @Route(.push) var customizeViewsSettings = makeCustomizeViewsSettings @Route(.push) @@ -45,26 +44,14 @@ final class SettingsCoordinator: NavigationCoordinatable { var indicatorSettings = makeIndicatorSettings @Route(.push) var serverConnection = makeServerConnection + @Route(.modal) + var adminDashboard = makeAdminDashboard @Route(.push) var videoPlayerSettings = makeVideoPlayerSettings @Route(.push) var customDeviceProfileSettings = makeCustomDeviceProfileSettings - - @Route(.push) - var userDashboard = makeUserDashboard - @Route(.push) - var activeSessions = makeActiveSessions - @Route(.push) - var activeDeviceDetails = makeActiveDeviceDetails @Route(.modal) var itemOverviewView = makeItemOverviewView - @Route(.push) - var tasks = makeTasks - @Route(.push) - var editScheduledTask = makeEditScheduledTask - @Route(.push) - var serverLogs = makeServerLogs - @Route(.modal) var editCustomDeviceProfile = makeEditCustomDeviceProfile @Route(.modal) @@ -162,19 +149,8 @@ final class SettingsCoordinator: NavigationCoordinatable { EditServerView(server: server) } - @ViewBuilder - func makeUserDashboard() -> some View { - UserDashboardView() - } - - @ViewBuilder - func makeActiveSessions() -> some View { - ActiveSessionsView() - } - - @ViewBuilder - func makeActiveDeviceDetails(box: BindingBox) -> some View { - ActiveSessionDetailView(box: box) + func makeAdminDashboard() -> NavigationViewCoordinator { + NavigationViewCoordinator(AdminDashboardCoordinator()) } func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator { @@ -183,21 +159,6 @@ final class SettingsCoordinator: NavigationCoordinatable { } } - @ViewBuilder - func makeTasks() -> some View { - ScheduledTasksView() - } - - @ViewBuilder - func makeEditScheduledTask(observer: ServerTaskObserver) -> some View { - EditScheduledTaskView(observer: observer) - } - - @ViewBuilder - func makeServerLogs() -> some View { - ServerLogsView() - } - func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View { OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases) .navigationTitle(L10n.filters) diff --git a/Shared/Extensions/JellyfinAPI/DayOfWeek.swift b/Shared/Extensions/JellyfinAPI/DayOfWeek.swift new file mode 100644 index 000000000..92f5fffc3 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DayOfWeek.swift @@ -0,0 +1,24 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +extension DayOfWeek { + + var displayTitle: String? { + let newLineRemoved = self.rawValue.replacingOccurrences(of: "\n", with: "") + + guard let index = DateFormatter().weekdaySymbols.firstIndex(of: newLineRemoved) else { + return nil + } + + let localCal = Calendar.current + return localCal.weekdaySymbols[index].localizedCapitalized + } +} diff --git a/Shared/Extensions/JellyfinAPI/TaskInfo.swift b/Shared/Extensions/JellyfinAPI/TaskInfo.swift new file mode 100644 index 000000000..ba0f48b5c --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/TaskInfo.swift @@ -0,0 +1,24 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +extension TaskState { + + var displayTitle: String { + switch self { + case .idle: + return L10n.taskStateIdle + case .cancelling: + return L10n.taskStateCancelling + case .running: + return L10n.taskStateRunning + } + } +} diff --git a/Shared/Extensions/TaskTriggerType.swift b/Shared/Extensions/TaskTriggerType.swift new file mode 100644 index 000000000..add2d13cf --- /dev/null +++ b/Shared/Extensions/TaskTriggerType.swift @@ -0,0 +1,45 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +// TODO: move to SDK as patch file + +enum TaskTriggerType: String, Codable, CaseIterable, Displayable, SystemImageable { + + case daily = "DailyTrigger" + case weekly = "WeeklyTrigger" + case interval = "IntervalTrigger" + case startup = "StartupTrigger" + + var displayTitle: String { + switch self { + case .daily: + return L10n.daily + case .weekly: + return L10n.weekly + case .interval: + return L10n.interval + case .startup: + return L10n.onApplicationStartup + } + } + + var systemImage: String { + switch self { + case .daily: + return "clock" + case .weekly: + return "calendar" + case .interval: + return "timer" + case .startup: + return "power" + } + } +} diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 4433c2d79..68b4d1ed8 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -20,8 +20,12 @@ internal enum L10n { internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility") /// Active Devices internal static let activeDevices = L10n.tr("Localizable", "activeDevices", fallback: "Active Devices") + /// Add + internal static let add = L10n.tr("Localizable", "add", fallback: "Add") /// Add Server internal static let addServer = L10n.tr("Localizable", "addServer", fallback: "Add Server") + /// Add Task Trigger + internal static let addTaskTrigger = L10n.tr("Localizable", "addTaskTrigger", fallback: "Add Task Trigger") /// Add URL internal static let addURL = L10n.tr("Localizable", "addURL", fallback: "Add URL") /// Administration @@ -32,6 +36,10 @@ internal enum L10n { internal static func airWithDate(_ p1: UnsafePointer) -> String { return L10n.tr("Localizable", "airWithDate", p1, fallback: "Airs %s") } + /// All Devices + internal static let allDevices = L10n.tr("Localizable", "allDevices", fallback: "All Devices") + /// Devices are all the hardware that have connected to the server, including both current and past connections. You can view device details such as name, app version, and the associated user. + internal static let allDevicesDescription = L10n.tr("Localizable", "allDevicesDescription", fallback: "Devices are all the hardware that have connected to the server, including both current and past connections. You can view device details such as name, app version, and the associated user.") /// All Genres internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: "All Genres") /// All Media @@ -146,6 +154,8 @@ internal enum L10n { internal static let category = L10n.tr("Localizable", "category", fallback: "Category") /// Change Server internal static let changeServer = L10n.tr("Localizable", "changeServer", fallback: "Change Server") + /// Changes not saved + internal static let changesNotSaved = L10n.tr("Localizable", "changesNotSaved", fallback: "Changes not saved") /// Channels internal static let channels = L10n.tr("Localizable", "channels", fallback: "Channels") /// Chapters @@ -212,6 +222,8 @@ internal enum L10n { internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: "Current Position") /// Custom internal static let custom = L10n.tr("Localizable", "custom", fallback: "Custom") + /// Custom Device Name + internal static let customDeviceName = L10n.tr("Localizable", "customDeviceName", fallback: "Custom Device Name") /// The custom device profiles will be added to the default Swiftfin device profiles internal static let customDeviceProfileAdd = L10n.tr("Localizable", "customDeviceProfileAdd", fallback: "The custom device profiles will be added to the default Swiftfin device profiles") /// Dictates back to the Jellyfin Server what this device hardware is capable of playing @@ -222,24 +234,66 @@ internal enum L10n { internal static let customize = L10n.tr("Localizable", "customize", fallback: "Customize") /// Custom Profile internal static let customProfile = L10n.tr("Localizable", "customProfile", fallback: "Custom Profile") + /// Daily + internal static let daily = L10n.tr("Localizable", "daily", fallback: "Daily") /// Dark internal static let dark = L10n.tr("Localizable", "dark", fallback: "Dark") /// Dashboard internal static let dashboard = L10n.tr("Localizable", "dashboard", fallback: "Dashboard") /// Perform administrative tasks for your Jellyfin server. internal static let dashboardDescription = L10n.tr("Localizable", "dashboardDescription", fallback: "Perform administrative tasks for your Jellyfin server.") + /// Day of Week + internal static let dayOfWeek = L10n.tr("Localizable", "dayOfWeek", fallback: "Day of Week") + /// Friday + internal static let dayOfWeekFriday = L10n.tr("Localizable", "dayOfWeekFriday", fallback: "Friday") + /// Monday + internal static let dayOfWeekMonday = L10n.tr("Localizable", "dayOfWeekMonday", fallback: "Monday") + /// Saturday + internal static let dayOfWeekSaturday = L10n.tr("Localizable", "dayOfWeekSaturday", fallback: "Saturday") + /// Sunday + internal static let dayOfWeekSunday = L10n.tr("Localizable", "dayOfWeekSunday", fallback: "Sunday") + /// Thursday + internal static let dayOfWeekThursday = L10n.tr("Localizable", "dayOfWeekThursday", fallback: "Thursday") + /// Tuesday + internal static let dayOfWeekTuesday = L10n.tr("Localizable", "dayOfWeekTuesday", fallback: "Tuesday") + /// Wednesday + internal static let dayOfWeekWednesday = L10n.tr("Localizable", "dayOfWeekWednesday", fallback: "Wednesday") /// Default Scheme internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") /// Delete internal static let delete = L10n.tr("Localizable", "delete", fallback: "Delete") + /// Delete All + internal static let deleteAll = L10n.tr("Localizable", "deleteAll", fallback: "Delete All") + /// Delete All Devices + internal static let deleteAllDevices = L10n.tr("Localizable", "deleteAllDevices", fallback: "Delete All Devices") + /// Are you sure you wish to delete all devices? All other sessions will be logged out. Devices will reappear the next time a user signs in. + internal static let deleteAllDevicesWarning = L10n.tr("Localizable", "deleteAllDevicesWarning", fallback: "Are you sure you wish to delete all devices? All other sessions will be logged out. Devices will reappear the next time a user signs in.") + /// Delete Device + internal static let deleteDevice = L10n.tr("Localizable", "deleteDevice", fallback: "Delete Device") + /// Failed to Delete Device + internal static let deleteDeviceFailed = L10n.tr("Localizable", "deleteDeviceFailed", fallback: "Failed to Delete Device") + /// Cannot delete a session from the same device (%1$@). + internal static func deleteDeviceSelfDeletion(_ p1: Any) -> String { + return L10n.tr("Localizable", "deleteDeviceSelfDeletion", String(describing: p1), fallback: "Cannot delete a session from the same device (%1$@).") + } + /// Are you sure you wish to delete this device? This session will be logged out. This device will reappear the next time this device signs in. + internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out. This device will reappear the next time this device signs in.") /// 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.") /// Delivery internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery") + /// Details + internal static let details = L10n.tr("Localizable", "details", fallback: "Details") /// Device internal static let device = L10n.tr("Localizable", "device", fallback: "Device") /// Device Profile internal static let deviceProfile = L10n.tr("Localizable", "deviceProfile", fallback: "Device Profile") + /// Devices + internal static let devices = L10n.tr("Localizable", "devices", fallback: "Devices") /// Direct Play internal static let direct = L10n.tr("Localizable", "direct", fallback: "Direct Play") /// DIRECTOR @@ -252,6 +306,8 @@ internal enum L10n { internal static let directStream = L10n.tr("Localizable", "directStream", fallback: "Direct Stream") /// Disabled internal static let disabled = L10n.tr("Localizable", "disabled", fallback: "Disabled") + /// Discard Changes + internal static let discardChanges = L10n.tr("Localizable", "discardChanges", fallback: "Discard Changes") /// Discovered Servers internal static let discoveredServers = L10n.tr("Localizable", "discoveredServers", fallback: "Discovered Servers") /// Dismiss @@ -272,6 +328,8 @@ internal enum L10n { internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp", fallback: "Empty Next Up") /// Enabled internal static let enabled = L10n.tr("Localizable", "enabled", fallback: "Enabled") + /// Supply a custom display name or leave empty to use the name reported by the device. + internal static let enterCustomDeviceName = L10n.tr("Localizable", "enterCustomDeviceName", fallback: "Supply a custom display name or leave empty to use the name reported by the device.") /// Episode Landscape Poster internal static let episodeLandscapePoster = L10n.tr("Localizable", "episodeLandscapePoster", fallback: "Episode Landscape Poster") /// Episode %1$@ @@ -282,6 +340,16 @@ internal enum L10n { internal static let episodes = L10n.tr("Localizable", "episodes", fallback: "Episodes") /// Error internal static let error = L10n.tr("Localizable", "error", fallback: "Error") + /// Error Details + internal static let errorDetails = L10n.tr("Localizable", "errorDetails", fallback: "Error Details") + /// Every + internal static let every = L10n.tr("Localizable", "every", fallback: "Every") + /// Every %1$@ + internal static func everyInterval(_ p1: Any) -> String { + return L10n.tr("Localizable", "everyInterval", String(describing: p1), fallback: "Every %1$@") + } + /// Executed + internal static let executed = L10n.tr("Localizable", "executed", fallback: "Executed") /// Existing Server internal static let existingServer = L10n.tr("Localizable", "existingServer", fallback: "Existing Server") /// Existing User @@ -314,16 +382,36 @@ internal enum L10n { internal static let hapticFeedback = L10n.tr("Localizable", "hapticFeedback", fallback: "Haptic Feedback") /// Home internal static let home = L10n.tr("Localizable", "home", fallback: "Home") + /// Hours + internal static let hours = L10n.tr("Localizable", "hours", fallback: "Hours") /// Indicators internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators") /// Information internal static let information = L10n.tr("Localizable", "information", fallback: "Information") /// Interlaced video is not supported internal static let interlacedVideoNotSupported = L10n.tr("Localizable", "interlacedVideoNotSupported", fallback: "Interlaced video is not supported") + /// Interval + internal static let interval = L10n.tr("Localizable", "interval", fallback: "Interval") + /// %d hour + internal static func intervalHour(_ p1: Int) -> String { + return L10n.tr("Localizable", "intervalHour", p1, fallback: "%d hour") + } + /// %d hours + internal static func intervalHours(_ p1: Int) -> String { + return L10n.tr("Localizable", "intervalHours", p1, fallback: "%d hours") + } + /// %d minutes + internal static func intervalMinutes(_ p1: Int) -> String { + return L10n.tr("Localizable", "intervalMinutes", p1, fallback: "%d minutes") + } /// Inverted Dark internal static let invertedDark = L10n.tr("Localizable", "invertedDark", fallback: "Inverted Dark") /// Inverted Light internal static let invertedLight = L10n.tr("Localizable", "invertedLight", fallback: "Inverted Light") + /// %1$@ at %2$@ + internal static func itemAtItem(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "itemAtItem", String(describing: p1), String(describing: p2), fallback: "%1$@ at %2$@") + } /// %1$@ / %2$@ internal static func itemOverItem(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "itemOverItem", String(describing: p1), String(describing: p2), fallback: "%1$@ / %2$@") @@ -390,6 +478,10 @@ internal enum L10n { } /// Logs internal static let logs = L10n.tr("Localizable", "logs", fallback: "Logs") + /// Access the Jellyfin server logs for troubleshooting and monitoring purposes. + internal static let logsDescription = L10n.tr("Localizable", "logsDescription", fallback: "Access the Jellyfin server logs for troubleshooting and monitoring purposes.") + /// Manual + internal static let manual = L10n.tr("Localizable", "manual", fallback: "Manual") /// Maximum Bitrate internal static let maximumBitrate = L10n.tr("Localizable", "maximumBitrate", fallback: "Maximum Bitrate") /// This setting may result in media failing to start playback @@ -420,6 +512,8 @@ internal enum L10n { internal static let networking = L10n.tr("Localizable", "networking", fallback: "Networking") /// Network timed out internal static let networkTimedOut = L10n.tr("Localizable", "networkTimedOut", fallback: "Network timed out") + /// Never + internal static let never = L10n.tr("Localizable", "never", fallback: "Never") /// Never run internal static let neverRun = L10n.tr("Localizable", "neverRun", fallback: "Never run") /// News @@ -454,6 +548,8 @@ internal enum L10n { internal static let noResults = L10n.tr("Localizable", "noResults", fallback: "No results.") /// Normal internal static let normal = L10n.tr("Localizable", "normal", fallback: "Normal") + /// No runtime limit + internal static let noRuntimeLimit = L10n.tr("Localizable", "noRuntimeLimit", fallback: "No runtime limit") /// No session internal static let noSession = L10n.tr("Localizable", "noSession", fallback: "No session") /// N/A @@ -468,6 +564,8 @@ internal enum L10n { internal static let offset = L10n.tr("Localizable", "offset", fallback: "Offset") /// Ok internal static let ok = L10n.tr("Localizable", "ok", fallback: "Ok") + /// On application startup + internal static let onApplicationStartup = L10n.tr("Localizable", "onApplicationStartup", fallback: "On application startup") /// 1 user internal static let oneUser = L10n.tr("Localizable", "oneUser", fallback: "1 user") /// Online @@ -636,6 +734,8 @@ internal enum L10n { internal static let running = L10n.tr("Localizable", "running", fallback: "Running...") /// Runtime internal static let runtime = L10n.tr("Localizable", "runtime", fallback: "Runtime") + /// Save + internal static let save = L10n.tr("Localizable", "save", fallback: "Save") /// Scan All Libraries internal static let scanAllLibraries = L10n.tr("Localizable", "scanAllLibraries", fallback: "Scan All Libraries") /// Scheduled Tasks @@ -754,6 +854,8 @@ internal enum L10n { internal static let specialFeatures = L10n.tr("Localizable", "specialFeatures", fallback: "Special Features") /// Sports internal static let sports = L10n.tr("Localizable", "sports", fallback: "Sports") + /// Status + internal static let status = L10n.tr("Localizable", "status", fallback: "Status") /// Stop internal static let stop = L10n.tr("Localizable", "stop", fallback: "Stop") /// Streams @@ -786,6 +888,8 @@ internal enum L10n { internal static let system = L10n.tr("Localizable", "system", fallback: "System") /// System Control Gestures Enabled internal static let systemControlGesturesEnabled = L10n.tr("Localizable", "systemControlGesturesEnabled", fallback: "System Control Gestures Enabled") + /// System Event + internal static let systemEvent = L10n.tr("Localizable", "systemEvent", fallback: "System Event") /// Tags internal static let tags = L10n.tr("Localizable", "tags", fallback: "Tags") /// Task @@ -802,8 +906,28 @@ internal enum L10n { internal static let tasks = L10n.tr("Localizable", "tasks", fallback: "Tasks") /// Tasks are operations that are scheduled to run periodically or can be triggered manually. internal static let tasksDescription = L10n.tr("Localizable", "tasksDescription", fallback: "Tasks are operations that are scheduled to run periodically or can be triggered manually.") + /// Cancelling + internal static let taskStateCancelling = L10n.tr("Localizable", "taskStateCancelling", fallback: "Cancelling") + /// Idle + internal static let taskStateIdle = L10n.tr("Localizable", "taskStateIdle", fallback: "Idle") + /// Running + internal static let taskStateRunning = L10n.tr("Localizable", "taskStateRunning", fallback: "Running") + /// Sets the maximum runtime (in hours) for this task trigger + internal static let taskTriggerTimeLimit = L10n.tr("Localizable", "taskTriggerTimeLimit", fallback: "Sets the maximum runtime (in hours) for this task trigger") /// Test Size internal static let testSize = L10n.tr("Localizable", "testSize", fallback: "Test Size") + /// Time + internal static let time = L10n.tr("Localizable", "time", fallback: "Time") + /// Time Limit + internal static let timeLimit = L10n.tr("Localizable", "timeLimit", fallback: "Time Limit") + /// Time limit: %1$@ + internal static func timeLimitLabelWithHours(_ p1: Any) -> String { + return L10n.tr("Localizable", "timeLimitLabelWithHours", String(describing: p1), fallback: "Time limit: %1$@") + } + /// Time Limit (%@) + internal static func timeLimitWithUnit(_ p1: Any) -> String { + return L10n.tr("Localizable", "timeLimitWithUnit", String(describing: p1), fallback: "Time Limit (%@)") + } /// Timestamp internal static let timestamp = L10n.tr("Localizable", "timestamp", fallback: "Timestamp") /// Timestamp Type @@ -818,6 +942,10 @@ internal enum L10n { internal static let transcodeReasons = L10n.tr("Localizable", "transcodeReasons", fallback: "Transcode Reason(s)") /// Transition internal static let transition = L10n.tr("Localizable", "transition", fallback: "Transition") + /// Triggers + internal static let triggers = L10n.tr("Localizable", "triggers", fallback: "Triggers") + /// Trigger Type + internal static let triggerType = L10n.tr("Localizable", "triggerType", fallback: "Trigger Type") /// Try again internal static let tryAgain = L10n.tr("Localizable", "tryAgain", fallback: "Try again") /// TV Shows @@ -842,6 +970,8 @@ internal enum L10n { internal static let unknownVideoStreamInfo = L10n.tr("Localizable", "unknownVideoStreamInfo", fallback: "The video stream information is unknown") /// Unplayed internal static let unplayed = L10n.tr("Localizable", "unplayed", fallback: "Unplayed") + /// You have unsaved changes. Are you sure you want to discard them? + internal static let unsavedChangesMessage = L10n.tr("Localizable", "unsavedChangesMessage", fallback: "You have unsaved changes. Are you sure you want to discard them?") /// URL internal static let url = L10n.tr("Localizable", "url", fallback: "URL") /// Use as Transcoding Profile @@ -858,6 +988,8 @@ internal enum L10n { } /// Username internal static let username = L10n.tr("Localizable", "username", fallback: "Username") + /// Users + internal static let users = L10n.tr("Localizable", "users", fallback: "Users") /// Version internal static let version = L10n.tr("Localizable", "version", fallback: "Version") /// Video @@ -882,6 +1014,8 @@ internal enum L10n { internal static let videoRangeTypeNotSupported = L10n.tr("Localizable", "videoRangeTypeNotSupported", fallback: "The video range type is not supported") /// The video resolution is not supported internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported") + /// Weekly + internal static let weekly = L10n.tr("Localizable", "weekly", fallback: "Weekly") /// Who's watching? internal static let whosWatching = L10n.tr("Localizable", "WhosWatching", fallback: "Who's watching?") /// WIP diff --git a/Shared/ViewModels/ActiveSessionsViewModel.swift b/Shared/ViewModels/ActiveSessionsViewModel.swift index d2dae1244..0f7bc01bd 100644 --- a/Shared/ViewModels/ActiveSessionsViewModel.swift +++ b/Shared/ViewModels/ActiveSessionsViewModel.swift @@ -40,6 +40,8 @@ final class ActiveSessionsViewModel: ViewModel, Stateful { @Published final var sessions: OrderedDictionary> = [:] @Published + final var activeSessions: OrderedDictionary> = [:] + @Published final var state: State = .initial private let activeWithinSeconds: Int = 960 @@ -167,6 +169,21 @@ final class ActiveSessionsViewModel: ViewModel, Stateful { return (xs?.lastActivityDate ?? Date.now) > (ys?.lastActivityDate ?? Date.now) } } + + activeSessions = sessions.filter { _, session in + session.value?.nowPlayingItem != nil + } + + activeSessions.sort { x, y in + let xs = x.value.value + let ys = y.value.value + + if xs?.userName != ys?.userName { + return (xs?.userName ?? "") < (ys?.userName ?? "") + } else { + return (xs?.nowPlayingItem?.name ?? "") < (ys?.nowPlayingItem?.name ?? "") + } + } } } } diff --git a/Shared/ViewModels/DevicesViewModel.swift b/Shared/ViewModels/DevicesViewModel.swift new file mode 100644 index 000000000..feba2f6d5 --- /dev/null +++ b/Shared/ViewModels/DevicesViewModel.swift @@ -0,0 +1,233 @@ +// +// 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 DevicesViewModel: ViewModel, Stateful { + + // MARK: - Action + + enum Action: Equatable { + case getDevices + case setCustomName(id: String, newName: String) + case deleteDevice(id: String) + case deleteAllDevices + } + + // MARK: - BackgroundState + + enum BackgroundState: Hashable { + case gettingDevices + case settingCustomName + case deletingDevice + case deletingAllDevices + } + + // MARK: - State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + } + + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var devices: OrderedDictionary> = [:] + @Published + final var state: State = .initial + + private var deviceTask: AnyCancellable? + + // MARK: - Respond to Action + + func respond(to action: Action) -> State { + switch action { + case .getDevices: + deviceTask?.cancel() + + backgroundStates.append(.gettingDevices) + + deviceTask = Task { [weak self] in + do { + try await self?.loadDevices() + await MainActor.run { + self?.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + 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 + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + self?.backgroundStates.remove(.settingCustomName) + } + } + .asAnyCancellable() + + return state + + case let .deleteDevice(id): + deviceTask?.cancel() + + backgroundStates.append(.deletingDevice) + + deviceTask = Task { [weak self] in + do { + try await self?.deleteDevice(id: id) + await MainActor.run { + self?.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + self?.backgroundStates.remove(.deletingDevice) + } + } + .asAnyCancellable() + + return state + + case .deleteAllDevices: + deviceTask?.cancel() + + backgroundStates.append(.deletingAllDevices) + + deviceTask = Task { [weak self] in + do { + try await self?.deleteAllDevices() + await MainActor.run { + self?.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + self?.backgroundStates.remove(.deletingAllDevices) + } + } + .asAnyCancellable() + + return state + } + } + + // MARK: - Load Devices + + private func loadDevices() async throws { + let request = Paths.getDevices() + let response = try await userSession.client.send(request) + + await MainActor.run { + if let devices = response.value.items { + 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( + 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 device = self.devices[id]?.value { + await MainActor.run { + self.devices[id]?.value?.name = newName + } + } + } + + // MARK: - Delete Device + + private func deleteDevice(id: String) async throws { + // Don't allow self-deletion + guard id != userSession.client.configuration.deviceID else { + return + } + + let request = Paths.deleteDevice(id: id) + try await userSession.client.send(request) + + await MainActor.run { + self.devices.removeValue(forKey: id) + } + } + + // MARK: - Delete All Devices + + private func deleteAllDevices() async throws { + let deviceIdsToDelete = self.devices.keys.filter { $0 != userSession.client.configuration.deviceID } + + for deviceId in deviceIdsToDelete { + try await deleteDevice(id: deviceId) + } + + await MainActor.run { + self.devices = self.devices.filter { $0.key == userSession.client.configuration.deviceID } + } + } +} diff --git a/Shared/ViewModels/ServerTaskObserver.swift b/Shared/ViewModels/ServerTaskObserver.swift index efc0ad59e..f7f23b602 100644 --- a/Shared/ViewModels/ServerTaskObserver.swift +++ b/Shared/ViewModels/ServerTaskObserver.swift @@ -11,7 +11,6 @@ import Foundation import JellyfinAPI // TODO: refactor with socket implementation -// TODO: edit triggers final class ServerTaskObserver: ViewModel, Stateful, Identifiable { @@ -19,11 +18,14 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { case start case stop case stopObserving + case addTrigger(TaskTriggerInfo) + case removeTrigger(TaskTriggerInfo) } enum State: Hashable { case error(JellyfinAPIError) case initial + case updating case running } @@ -89,6 +91,44 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { cancelCancellable?.cancel() return .initial + case let .addTrigger(trigger): + progressCancellable?.cancel() + cancelCancellable?.cancel() + + cancelCancellable = Task { + do { + try await addTrigger(newTrigger: trigger) + await MainActor.run { + self.state = .updating + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .running + case let .removeTrigger(trigger): + progressCancellable?.cancel() + cancelCancellable?.cancel() + + cancelCancellable = Task { + do { + try await removeTrigger(deleteTrigger: trigger) + await MainActor.run { + self.state = .updating + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .running } } @@ -124,4 +164,26 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { let request = Paths.stopTask(taskID: id) try await userSession.client.send(request) } + + private func addTrigger(newTrigger: TaskTriggerInfo) async throws { + var updatedTriggers = task.triggers ?? [] + updatedTriggers.append(newTrigger) + try await updateTriggers(updatedTriggers) + } + + private func removeTrigger(deleteTrigger: TaskTriggerInfo) async throws { + var updatedTriggers = task.triggers ?? [] + updatedTriggers.removeAll { $0 == deleteTrigger } + try await updateTriggers(updatedTriggers) + } + + private func updateTriggers(_ updatedTriggers: [TaskTriggerInfo]) async throws { + guard let id = task.id else { return } + let updateRequest = Paths.updateTask(taskID: id, updatedTriggers) + try await userSession.client.send(updateRequest) + + await MainActor.run { + self.task.triggers = updatedTriggers + } + } } diff --git a/Shared/ViewModels/UserAdministrationObserver.swift b/Shared/ViewModels/UserAdministrationObserver.swift new file mode 100644 index 000000000..112336bce --- /dev/null +++ b/Shared/ViewModels/UserAdministrationObserver.swift @@ -0,0 +1,119 @@ +// +// 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 + +final class UserAdministrationObserver: ViewModel, Stateful, Identifiable { + + enum Action: Equatable { + case resetPassword + case updatePassword(currentPassword: String?, newPassword: String) + case stopObserving + } + + enum State: Hashable { + case error(JellyfinAPIError) + case initial + case updating + case running + } + + @Published + final var state: State = .initial + @Published + private(set) var user: UserDto + + private var progressCancellable: AnyCancellable? + private var cancelCancellable: AnyCancellable? + + var id: String? { user.id } + + init(user: UserDto) { + self.user = user + } + + func respond(to action: Action) -> State { + switch action { + case .resetPassword: + if case .running = state { + return state + } + + progressCancellable = Task { + do { + try await resetPassword() + + await MainActor.run { + self.state = .initial + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .running + + case let .updatePassword(currentPassword, newPassword): + if case .running = state { + return state + } + + progressCancellable = Task { + do { + try await updatePassword( + currentPw: currentPassword, + newPw: newPassword + ) + + await MainActor.run { + self.state = .initial + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .running + + case .stopObserving: + progressCancellable?.cancel() + cancelCancellable?.cancel() + + return .initial + } + } + + // MARK: - Reset Password + + private func resetPassword() async throws { + guard let userId = user.id else { return } + let parameters = UpdateUserPassword(isResetPassword: true) + let updateRequest = Paths.updateUserPassword(userID: userId, parameters) + try await userSession.client.send(updateRequest) + } + + // MARK: - Update Password + + private func updatePassword(currentPw: String? = nil, newPw: String) async throws { + guard let userId = user.id else { return } + let parameters = UpdateUserPassword( + currentPw: currentPw, + newPw: newPw + ) + let updateRequest = Paths.updateUserPassword(userID: userId, parameters) + try await userSession.client.send(updateRequest) + } +} diff --git a/Shared/ViewModels/UserAdministrationViewModel.swift b/Shared/ViewModels/UserAdministrationViewModel.swift new file mode 100644 index 000000000..d9fafc6c5 --- /dev/null +++ b/Shared/ViewModels/UserAdministrationViewModel.swift @@ -0,0 +1,105 @@ +// +// 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 UserAdministrationViewModel: ViewModel, Stateful { + + // MARK: - Action + + enum Action: Equatable { + case getUsers + } + + // MARK: - BackgroundState + + enum BackgroundState: Hashable { + case gettingUsers + } + + // MARK: - State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + } + + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var users: OrderedDictionary> = [:] + + @Published + final var state: State = .initial + + private var userTask: AnyCancellable? + + // MARK: - Respond to Action + + func respond(to action: Action) -> State { + switch action { + case .getUsers: + userTask?.cancel() + + backgroundStates.append(.gettingUsers) + + userTask = Task { [weak self] in + do { + try await self?.loadUsers() + await MainActor.run { + self?.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + self?.backgroundStates.remove(.gettingUsers) + } + } + .asAnyCancellable() + + return state + } + } + + // MARK: - Load Users + + private func loadUsers() async throws { + let request = Paths.getUsers() + let response = try await userSession.client.send(request) + + await MainActor.run { + for user in response.value { + guard let id = user.id else { continue } + + if let existingUser = self.users[id] { + existingUser.value = user + } else { + self.users[id] = BindingBox( + source: .init(get: { user }, set: { _ in }) + ) + } + } + + self.users.sort { x, y in + let user0 = x.value.value + let user1 = y.value.value + return (user0?.name ?? "") < (user1?.name ?? "") + } + } + } +} diff --git a/Swiftfin tvOS/Components/ChevronInputButton.swift b/Swiftfin tvOS/Components/ChevronInputButton.swift new file mode 100644 index 000000000..c5bd7ee10 --- /dev/null +++ b/Swiftfin tvOS/Components/ChevronInputButton.swift @@ -0,0 +1,151 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct ChevronInputButton: View where Content: View { + + @State + private var isSelected = false + + private let title: Text + private let subtitle: Text? + private var leadingView: () -> any View + private let menu: () -> Content + private let onSave: (() -> Void)? + private let onCancel: (() -> Void)? + private let description: String? + + // MARK: - Initializer: String Title, String Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + title: String, + subtitle: String, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = Text(title) + self.subtitle = Text(subtitle) + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: String Title, Text Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + title: String, + subtitleText: Text, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = Text(title) + self.subtitle = subtitleText + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: Text Title, String Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + titleText: Text, + subtitle: String, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = titleText + self.subtitle = Text(subtitle) + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: Text Title, Text Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + titleText: Text, + subtitleText: Text, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = titleText + self.subtitle = subtitleText + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Leading View Customization + + func leadingView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + var copy = self + copy.leadingView = content + return copy + } + + // MARK: - Body + + var body: some View { + Button { + isSelected = true + } label: { + HStack { + leadingView() + .eraseToAnyView() + + title + .foregroundColor(.primary) + + Spacer() + + if let subtitle { + subtitle + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .alert(title, isPresented: $isSelected) { + menu() + + Button(L10n.save) { + onSave?() + isSelected = false + } + Button(L10n.cancel, role: .cancel) { + onCancel?() + isSelected = false + } + } message: { + if let description = description { + Text(description) + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift index e7d76ee78..a56e62050 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift @@ -20,7 +20,7 @@ extension CustomizeViewsSettings { private var resumeNextUp @State - private var isPresentingNextUpDays = false + var tempNextUp: TimeInterval? var body: some View { Section(L10n.home) { @@ -29,9 +29,9 @@ extension CustomizeViewsSettings { Toggle(L10n.nextUpRewatch, isOn: $resumeNextUp) - ChevronButton( - L10n.nextUpDays, - subtitle: { + ChevronInputButton( + title: L10n.nextUpDays, + subtitleText: { if maxNextUp > 0 { return Text( Date.now.addingTimeInterval(-maxNextUp) ..< Date.now, @@ -40,23 +40,21 @@ extension CustomizeViewsSettings { } else { return Text(L10n.disabled) } - }() - ) - .onSelect { - isPresentingNextUpDays = true - } - .alert(L10n.nextUpDays, isPresented: $isPresentingNextUpDays) { - - // TODO: Validate whether this says Done or a Number + }(), + description: L10n.nextUpDaysDescription + ) { TextField( L10n.nextUpDays, - value: $maxNextUp, - format: .dayInterval(range: 0 ... 1000) + value: $tempNextUp, + format: .number ) .keyboardType(.numberPad) - - } message: { - L10n.nextUpDaysDescription.text + } onSave: { + if let tempNextUp = tempNextUp { + maxNextUp = tempNextUp + } + } onCancel: { + tempNextUp = maxNextUp } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 12401b08b..cd1816f09 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -20,6 +20,17 @@ 4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */; }; 4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; + 4E24D5C12CBC44F4000FD1C0 /* AdminDashboardCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5BF2CBC44EE000FD1C0 /* AdminDashboardCoordinator.swift */; }; + 4E24D5C52CBC8742000FD1C0 /* ChevronInputButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5C42CBC873D000FD1C0 /* ChevronInputButton.swift */; }; + 4E24D5CC2CBC9846000FD1C0 /* UserAdministrationDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5CA2CBC9846000FD1C0 /* UserAdministrationDetailView.swift */; }; + 4E24D5D12CBC9B47000FD1C0 /* UserAdministrationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D02CBC9B47000FD1C0 /* UserAdministrationObserver.swift */; }; + 4E24D5D32CBCB2B3000FD1C0 /* UserAdministrationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D22CBCB2AD000FD1C0 /* UserAdministrationViewModel.swift */; }; + 4E24D5D42CBCB2B3000FD1C0 /* UserAdministrationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D22CBCB2AD000FD1C0 /* UserAdministrationViewModel.swift */; }; + 4E24D5DE2CBCBED7000FD1C0 /* UserAdministrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5DC2CBCBED7000FD1C0 /* UserAdministrationView.swift */; }; + 4E24D5DF2CBCBED7000FD1C0 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5DA2CBCBED7000FD1C0 /* UserProfileView.swift */; }; + 4E24D5E02CBCBED7000FD1C0 /* UserAdministrationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D92CBCBED7000FD1C0 /* UserAdministrationRow.swift */; }; + 4E24D5E12CBCBED7000FD1C0 /* UserAdministrationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5D82CBCBED7000FD1C0 /* UserAdministrationButton.swift */; }; + 4E24D5E32CBCC9E3000FD1C0 /* UserFunctionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E24D5E22CBCC9DA000FD1C0 /* UserFunctionButton.swift */; }; 4E2AC4BE2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; }; 4E2AC4BF2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; }; 4E2AC4C22C6C491200DD600D /* AudoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */; }; @@ -34,8 +45,13 @@ 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */; }; 4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; }; 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; }; + 4E3A2BE72CBC1E7B00B241A7 /* ActiveSessionIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A2BE62CBC1E7500B241A7 /* ActiveSessionIndicator.swift */; }; + 4E59E92A2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */; }; + 4E59E92B2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */; }; + 4E59E92F2CB64CD100FA28E1 /* TaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */; }; + 4E59E9302CB64CD100FA28E1 /* TaskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; - 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */; }; + 4E63B9FA2C8A5BEF00C25378 /* AdminDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */; }; 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; }; 4E699BB92CB33FC2007CBD5D /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E699BB82CB33FB5007CBD5D /* HomeSection.swift */; }; 4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E699BBF2CB34775007CBD5D /* HomeSection.swift */; }; @@ -45,6 +61,10 @@ 4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; 4E762AAF2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; + 4E872ABE2CB84BF2008C17BC /* DeviceTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E872ABD2CB84BEF008C17BC /* DeviceTypes.swift */; }; + 4E872AC32CB861F5008C17BC /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E872AC12CB861F5008C17BC /* DevicesView.swift */; }; + 4E872AC42CB861F5008C17BC /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E872ABF2CB861F5008C17BC /* DeviceRow.swift */; }; + 4E872AC62CB86308008C17BC /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E872AC52CB86308008C17BC /* DevicesViewModel.swift */; }; 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; 4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */; }; @@ -52,6 +72,22 @@ 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; 4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; }; 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; }; + 4E9DB3E62CB7023900D36A26 /* ChevronInputButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3E52CB7021F00D36A26 /* ChevronInputButton.swift */; }; + 4E9DB3E82CB726DF00D36A26 /* ServerTicks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3E72CB726DC00D36A26 /* ServerTicks.swift */; }; + 4E9DB3F62CB7969100D36A26 /* DetailsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3F52CB7968E00D36A26 /* DetailsSection.swift */; }; + 4E9DB3F82CB796B100D36A26 /* LastRunSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3F72CB796AE00D36A26 /* LastRunSection.swift */; }; + 4E9DB3FA2CB796D000D36A26 /* LastErrorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3F92CB796CB00D36A26 /* LastErrorSection.swift */; }; + 4E9DB3FC2CB796ED00D36A26 /* CurrentRunningSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3FB2CB796E500D36A26 /* CurrentRunningSection.swift */; }; + 4E9DB3FE2CB7972A00D36A26 /* TriggersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DB3FD2CB7972600D36A26 /* TriggersSection.swift */; }; + 4EA556B12CB48BB600F71E7A /* AddTaskTriggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */; }; + 4EA556B22CB48BB600F71E7A /* TriggerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B02CB48BB600F71E7A /* TriggerRow.swift */; }; + 4EA556B42CB48D1600F71E7A /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */; }; + 4EA556B52CB48D1600F71E7A /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */; }; + 4EA556BB2CB4A1F500F71E7A /* TriggerTypeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */; }; + 4EA556BD2CB4A21500F71E7A /* DayOfWeekSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */; }; + 4EA556BF2CB4A22C00F71E7A /* TimeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */; }; + 4EA556C12CB4A24B00F71E7A /* IntervalSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556C02CB4A24A00F71E7A /* IntervalSection.swift */; }; + 4EA556C32CB4A26100F71E7A /* TimeLimitSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA556C22CB4A26000F71E7A /* TimeLimitSection.swift */; }; 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; }; 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; }; 4EB1A8CC2C9B1BA200F43898 /* ServerTaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */; }; @@ -1023,6 +1059,16 @@ 4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTaskButton.swift; sourceTree = ""; }; 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = ""; }; 4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = ""; }; + 4E24D5BF2CBC44EE000FD1C0 /* AdminDashboardCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDashboardCoordinator.swift; sourceTree = ""; }; + 4E24D5C42CBC873D000FD1C0 /* ChevronInputButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronInputButton.swift; sourceTree = ""; }; + 4E24D5CA2CBC9846000FD1C0 /* UserAdministrationDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationDetailView.swift; sourceTree = ""; }; + 4E24D5D02CBC9B47000FD1C0 /* UserAdministrationObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationObserver.swift; sourceTree = ""; }; + 4E24D5D22CBCB2AD000FD1C0 /* UserAdministrationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationViewModel.swift; sourceTree = ""; }; + 4E24D5D82CBCBED7000FD1C0 /* UserAdministrationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationButton.swift; sourceTree = ""; }; + 4E24D5D92CBCBED7000FD1C0 /* UserAdministrationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationRow.swift; sourceTree = ""; }; + 4E24D5DA2CBCBED7000FD1C0 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; + 4E24D5DC2CBCBED7000FD1C0 /* UserAdministrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAdministrationView.swift; sourceTree = ""; }; + 4E24D5E22CBCC9DA000FD1C0 /* UserFunctionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFunctionButton.swift; sourceTree = ""; }; 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileAction.swift; sourceTree = ""; }; 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudoCodec.swift; sourceTree = ""; }; 4E2AC4C42C6C492700DD600D /* MediaContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainer.swift; sourceTree = ""; }; @@ -1032,8 +1078,11 @@ 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; + 4E3A2BE62CBC1E7500B241A7 /* ActiveSessionIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionIndicator.swift; sourceTree = ""; }; + 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeek.swift; sourceTree = ""; }; + 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskInfo.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; - 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDashboardView.swift; sourceTree = ""; }; + 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = ""; }; 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = ""; }; 4E699BB82CB33FB5007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = ""; }; 4E699BBF2CB34775007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = ""; }; @@ -1041,11 +1090,30 @@ 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; + 4E872ABD2CB84BEF008C17BC /* DeviceTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTypes.swift; sourceTree = ""; }; + 4E872ABF2CB861F5008C17BC /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = ""; }; + 4E872AC12CB861F5008C17BC /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = ""; }; + 4E872AC52CB86308008C17BC /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = ""; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = ""; }; 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = ""; }; 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; + 4E9DB3E52CB7021F00D36A26 /* ChevronInputButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronInputButton.swift; sourceTree = ""; }; + 4E9DB3E72CB726DC00D36A26 /* ServerTicks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTicks.swift; sourceTree = ""; }; + 4E9DB3F52CB7968E00D36A26 /* DetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsSection.swift; sourceTree = ""; }; + 4E9DB3F72CB796AE00D36A26 /* LastRunSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastRunSection.swift; sourceTree = ""; }; + 4E9DB3F92CB796CB00D36A26 /* LastErrorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastErrorSection.swift; sourceTree = ""; }; + 4E9DB3FB2CB796E500D36A26 /* CurrentRunningSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentRunningSection.swift; sourceTree = ""; }; + 4E9DB3FD2CB7972600D36A26 /* TriggersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggersSection.swift; sourceTree = ""; }; + 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTaskTriggerView.swift; sourceTree = ""; }; + 4EA556B02CB48BB600F71E7A /* TriggerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRow.swift; sourceTree = ""; }; + 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerType.swift; sourceTree = ""; }; + 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTypeSection.swift; sourceTree = ""; }; + 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekSection.swift; sourceTree = ""; }; + 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSection.swift; sourceTree = ""; }; + 4EA556C02CB4A24A00F71E7A /* IntervalSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalSection.swift; sourceTree = ""; }; + 4EA556C22CB4A26000F71E7A /* TimeLimitSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLimitSection.swift; sourceTree = ""; }; 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = ""; }; 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = ""; }; 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskButton.swift; sourceTree = ""; }; @@ -1835,6 +1903,42 @@ path = Components; sourceTree = ""; }; + 4E24D5C92CBC9846000FD1C0 /* Components */ = { + isa = PBXGroup; + children = ( + 4E24D5E22CBCC9DA000FD1C0 /* UserFunctionButton.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4E24D5CB2CBC9846000FD1C0 /* UserAdministrationDetailView */ = { + isa = PBXGroup; + children = ( + 4E24D5C92CBC9846000FD1C0 /* Components */, + 4E24D5CA2CBC9846000FD1C0 /* UserAdministrationDetailView.swift */, + ); + path = UserAdministrationDetailView; + sourceTree = ""; + }; + 4E24D5DB2CBCBED7000FD1C0 /* Components */ = { + isa = PBXGroup; + children = ( + 4E24D5D82CBCBED7000FD1C0 /* UserAdministrationButton.swift */, + 4E24D5D92CBCBED7000FD1C0 /* UserAdministrationRow.swift */, + 4E24D5DA2CBCBED7000FD1C0 /* UserProfileView.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4E24D5DD2CBCBED7000FD1C0 /* UserAdministrationView */ = { + isa = PBXGroup; + children = ( + 4E24D5DB2CBCBED7000FD1C0 /* Components */, + 4E24D5DC2CBCBED7000FD1C0 /* UserAdministrationView.swift */, + ); + path = UserAdministrationView; + sourceTree = ""; + }; 4E2AC4C02C6C48EB00DD600D /* MediaComponents */ = { isa = PBXGroup; children = ( @@ -1855,17 +1959,21 @@ path = PlaybackBitrate; sourceTree = ""; }; - 4E63B9F52C8A5BEF00C25378 /* UserDashboardView */ = { + 4E63B9F52C8A5BEF00C25378 /* AdminDashboardView */ = { isa = PBXGroup; children = ( 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, - E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */, + 4E9DB3F22CB7952200D36A26 /* AddTaskTriggerView */, + 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */, + 4E872AC22CB861F5008C17BC /* DevicesView */, + 4EA556AD2CB48B5900F71E7A /* EditScheduledTaskView */, 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */, - E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, - 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */, + 4E872AC72CB86381008C17BC /* ServerLogsView */, + 4E24D5CB2CBC9846000FD1C0 /* UserAdministrationDetailView */, + 4E24D5DD2CBCBED7000FD1C0 /* UserAdministrationView */, ); - path = UserDashboardView; + path = AdminDashboardView; sourceTree = ""; }; 4E699BB52CB33F4B007CBD5D /* CustomizeViewsSettings */ = { @@ -1927,6 +2035,47 @@ path = ActiveSessionDetailView; sourceTree = ""; }; + 4E872ABC2CB843F9008C17BC /* Sections */ = { + isa = PBXGroup; + children = ( + 4EE141682C8BABDF0045B661 /* ProgressSection.swift */, + ); + path = Sections; + sourceTree = ""; + }; + 4E872AC02CB861F5008C17BC /* Components */ = { + isa = PBXGroup; + children = ( + 4E872ABF2CB861F5008C17BC /* DeviceRow.swift */, + 4E872ACD2CB8A0A1008C17BC /* Sections */, + ); + path = Components; + sourceTree = ""; + }; + 4E872AC22CB861F5008C17BC /* DevicesView */ = { + isa = PBXGroup; + children = ( + 4E872AC02CB861F5008C17BC /* Components */, + 4E872AC12CB861F5008C17BC /* DevicesView.swift */, + ); + path = DevicesView; + sourceTree = ""; + }; + 4E872AC72CB86381008C17BC /* ServerLogsView */ = { + isa = PBXGroup; + children = ( + E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, + ); + path = ServerLogsView; + sourceTree = ""; + }; + 4E872ACD2CB8A0A1008C17BC /* Sections */ = { + isa = PBXGroup; + children = ( + ); + path = Sections; + sourceTree = ""; + }; 4E9A24E32C82B4700023DA83 /* CustomDeviceProfileSettingsView */ = { isa = PBXGroup; children = ( @@ -1945,6 +2094,65 @@ path = Components; sourceTree = ""; }; + 4E9DB3F22CB7952200D36A26 /* AddTaskTriggerView */ = { + isa = PBXGroup; + children = ( + 4EA556AF2CB48BB600F71E7A /* AddTaskTriggerView.swift */, + 4E9DB3F32CB7954700D36A26 /* Components */, + ); + path = AddTaskTriggerView; + sourceTree = ""; + }; + 4E9DB3F32CB7954700D36A26 /* Components */ = { + isa = PBXGroup; + children = ( + 4E9DB3F42CB7954E00D36A26 /* Sections */, + ); + path = Components; + sourceTree = ""; + }; + 4E9DB3F42CB7954E00D36A26 /* Sections */ = { + isa = PBXGroup; + children = ( + 4EA556BC2CB4A21400F71E7A /* DayOfWeekSection.swift */, + 4EA556C02CB4A24A00F71E7A /* IntervalSection.swift */, + 4EA556C22CB4A26000F71E7A /* TimeLimitSection.swift */, + 4EA556BE2CB4A22B00F71E7A /* TimeSection.swift */, + 4EA556BA2CB4A1F400F71E7A /* TriggerTypeSection.swift */, + ); + path = Sections; + sourceTree = ""; + }; + 4EA556AD2CB48B5900F71E7A /* EditScheduledTaskView */ = { + isa = PBXGroup; + children = ( + 4EA556AE2CB48B6600F71E7A /* Components */, + E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */, + ); + path = EditScheduledTaskView; + sourceTree = ""; + }; + 4EA556AE2CB48B6600F71E7A /* Components */ = { + isa = PBXGroup; + children = ( + 4EA556B02CB48BB600F71E7A /* TriggerRow.swift */, + 4EA556B92CB4A0BE00F71E7A /* Sections */, + ); + path = Components; + sourceTree = ""; + }; + 4EA556B92CB4A0BE00F71E7A /* Sections */ = { + isa = PBXGroup; + children = ( + 4E9DB3FB2CB796E500D36A26 /* CurrentRunningSection.swift */, + 4E9DB3F52CB7968E00D36A26 /* DetailsSection.swift */, + 4E9DB3F92CB796CB00D36A26 /* LastErrorSection.swift */, + 4E9DB3F72CB796AE00D36A26 /* LastRunSection.swift */, + 4E9DB3FD2CB7972600D36A26 /* TriggersSection.swift */, + ); + path = Sections; + sourceTree = ""; + }; 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */ = { isa = PBXGroup; children = ( @@ -1958,7 +2166,7 @@ isa = PBXGroup; children = ( 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */, - 4EE141682C8BABDF0045B661 /* ProgressSection.swift */, + 4E872ABC2CB843F9008C17BC /* Sections */, ); path = Components; sourceTree = ""; @@ -2010,6 +2218,7 @@ 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */, E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, + 4E872AC52CB86308008C17BC /* DevicesViewModel.swift */, E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, @@ -2028,6 +2237,8 @@ E1ED7FE12CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift */, E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, + 4E24D5D02CBC9B47000FD1C0 /* UserAdministrationObserver.swift */, + 4E24D5D22CBCB2AD000FD1C0 /* UserAdministrationViewModel.swift */, E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */, E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, @@ -2171,6 +2382,7 @@ isa = PBXGroup; children = ( E12E30F229638B140022FAC9 /* ChevronButton.swift */, + 4E24D5C42CBC873D000FD1C0 /* ChevronInputButton.swift */, E1DC9818296DD1CD00982F06 /* CinematicBackgroundView.swift */, E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */, E1C92618288756BD002A7A66 /* DotHStack.swift */, @@ -2405,10 +2617,13 @@ 53F866422687A45400DCD1D7 /* Components */ = { isa = PBXGroup; children = ( + 4E3A2BE62CBC1E7500B241A7 /* ActiveSessionIndicator.swift */, E1D8429429346C6400D1041A /* BasicStepper.swift */, E1A1528728FD229500600579 /* ChevronButton.swift */, + 4E9DB3E52CB7021F00D36A26 /* ChevronInputButton.swift */, E133328C2953AE4B00EE76AB /* CircularProgressView.swift */, E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */, + 4E872ABD2CB84BEF008C17BC /* DeviceTypes.swift */, E18E01A7288746AF0022598C /* DotHStack.swift */, E1DE2B492B97ECB900F6715F /* ErrorView.swift */, E1921B7528E63306003A5238 /* GestureView.swift */, @@ -2426,6 +2641,7 @@ E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */, E1AA331C2782541500F6439C /* PrimaryButton.swift */, E1D3043428D1763100587289 /* SeeAllButton.swift */, + 4E9DB3E72CB726DC00D36A26 /* ServerTicks.swift */, E17DC74C2BE7601E00B42379 /* SettingsBarButton.swift */, E1D5C39728DF914100CDBEFB /* Slider */, E1581E26291EF59800D6C640 /* SplitContentView.swift */, @@ -2466,6 +2682,7 @@ E145EB442BE0AD4E003BF6F3 /* Set.swift */, 621338922660107500A81A2A /* String.swift */, E1DD55362B6EE533007501C0 /* Task.swift */, + 4EA556B32CB48D1600F71E7A /* TaskTriggerType.swift */, E1F5CF072CB0A04500607465 /* Text.swift */, E1A2C153279A7D5A005EC829 /* UIApplication.swift */, E1401CB029386C9200E8B599 /* UIColor.swift */, @@ -2494,6 +2711,7 @@ 62C29E9D26D0FE5900C1D2E7 /* Coordinators */ = { isa = PBXGroup; children = ( + 4E24D5BF2CBC44EE000FD1C0 /* AdminDashboardCoordinator.swift */, E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */, E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */, 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */, @@ -2977,6 +3195,7 @@ isa = PBXGroup; children = ( E18E01F3288747580022598C /* AboutAppView.swift */, + 4E63B9F52C8A5BEF00C25378 /* AdminDashboardView */, E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */, E1763A732BF3FA4C004DF6AB /* AppLoadingView.swift */, E164A7F12BE471E700A54B18 /* AppSettingsView */, @@ -3538,10 +3757,9 @@ E1D37F5B2B9CF02600343D2B /* BaseItemDto */, E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */, E1002B632793CEE700E47059 /* ChapterInfo.swift */, - E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */, 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */, - 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */, + 4E59E9292CB62E2300FA28E1 /* DayOfWeek.swift */, E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */, E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */, E1D842902933F87500D1041A /* ItemFields.swift */, @@ -3551,10 +3769,13 @@ E1F5F9B12BA0200500BA5014 /* MediaSourceInfo */, E122A9122788EAAD0060FA63 /* MediaStream.swift */, E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */, - E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */, + E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, + E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */, E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */, E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */, + 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */, + 4E59E92E2CB64CCC00FA28E1 /* TaskInfo.swift */, 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */, E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */, E18CE0B128A229E70092E7F1 /* UserDto.swift */, @@ -3822,7 +4043,6 @@ E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */, 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */, E1BE1CEB2BDB68BC008176A9 /* SettingsView */, - 4E63B9F52C8A5BEF00C25378 /* UserDashboardView */, E1545BD62BDC559500D9578F /* UserProfileSettingsView */, E1BDF2E7295148F400CC0294 /* VideoPlayerSettingsView */, ); @@ -4329,6 +4549,7 @@ 4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */, E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */, E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, + 4EA556B42CB48D1600F71E7A /* TaskTriggerType.swift in Sources */, E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */, E1ED7FDB2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */, E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */, @@ -4392,6 +4613,7 @@ 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, E150C0BB2BFD44F500944FFA /* ImagePipeline.swift in Sources */, + 4E24D5C52CBC8742000FD1C0 /* ChevronInputButton.swift in Sources */, E11E0E8D2BF7E76F007676DD /* DataCache.swift in Sources */, E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */, E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, @@ -4441,6 +4663,7 @@ E150C0BE2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */, E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, + 4E24D5D42CBCB2B3000FD1C0 /* UserAdministrationViewModel.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, 4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */, @@ -4452,6 +4675,7 @@ E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */, E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */, + 4E59E92B2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */, E1B4E4372CA7795200DC49DE /* OrderedDictionary.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */, @@ -4562,6 +4786,7 @@ C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */, + 4E59E92F2CB64CD100FA28E1 /* TaskInfo.swift in Sources */, 4E16FD582C01A32700110147 /* LetterPickerOrientation.swift in Sources */, E19070502C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */, E1575E70293E77B5001665B1 /* TextPair.swift in Sources */, @@ -4625,6 +4850,7 @@ E11245B428D97D5D00D8A977 /* BottomBarView.swift in Sources */, E17FB55528C1250B00311DFE /* SimilarItemsHStack.swift in Sources */, C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */, + 4E24D5D12CBC9B47000FD1C0 /* UserAdministrationObserver.swift in Sources */, 5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */, @@ -4632,6 +4858,7 @@ 6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */, E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */, 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */, + 4E24D5D32CBCB2B3000FD1C0 /* UserAdministrationViewModel.swift in Sources */, E1A1528828FD229500600579 /* ChevronButton.swift in Sources */, E1CB75732C80E71800217C76 /* DirectPlayProfile.swift in Sources */, E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */, @@ -4650,12 +4877,14 @@ E1C812C5277A90B200918266 /* URLComponents.swift in Sources */, E1C925F428875037002A7A66 /* ItemViewType.swift in Sources */, E17DC74A2BE740D900B42379 /* StoredValues+Server.swift in Sources */, + 4EA556BB2CB4A1F500F71E7A /* TriggerTypeSection.swift in Sources */, 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* MediaCoordinator.swift in Sources */, E1F5CF052CB09EA000607465 /* CurrentDate.swift in Sources */, E13316FE2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */, 62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */, + 4E24D5CC2CBC9846000FD1C0 /* UserAdministrationDetailView.swift in Sources */, E1A1528A28FD22F600600579 /* TextPairView.swift in Sources */, E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */, E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, @@ -4670,6 +4899,7 @@ 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, E11895AF2893840F0042947B /* NavigationBarOffsetView.swift in Sources */, + 4E9DB3F62CB7969100D36A26 /* DetailsSection.swift in Sources */, E18E0208288749200022598C /* BlurView.swift in Sources */, E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */, E1E1643F28BB075C00323B0A /* SelectorView.swift in Sources */, @@ -4695,6 +4925,7 @@ E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */, E150C0BA2BFD44F500944FFA /* ImagePipeline.swift in Sources */, E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */, + 4E9DB3FC2CB796ED00D36A26 /* CurrentRunningSection.swift in Sources */, E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */, 4E2AC4BE2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */, E18E01FA288747580022598C /* AboutAppView.swift in Sources */, @@ -4711,6 +4942,7 @@ 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, E133328829538D8D00EE76AB /* Files.swift in Sources */, + 4E59E9302CB64CD100FA28E1 /* TaskInfo.swift in Sources */, C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */, E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, BD39577E2C1140810078CEF8 /* TransitionSection.swift in Sources */, @@ -4719,14 +4951,19 @@ E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */, E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */, E145EB452BE0AD4E003BF6F3 /* Set.swift in Sources */, + 4E872ABE2CB84BF2008C17BC /* DeviceTypes.swift in Sources */, + 4E872AC32CB861F5008C17BC /* DevicesView.swift in Sources */, + 4E872AC42CB861F5008C17BC /* DeviceRow.swift in Sources */, E1BDF2F929524FDA00CC0294 /* PlayPreviousItemActionButton.swift in Sources */, C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, + 4EA556C12CB4A24B00F71E7A /* IntervalSection.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */, E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */, E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */, + 4E24D5DE2CBCBED7000FD1C0 /* UserAdministrationView.swift in Sources */, 4E2AC4C82C6C493C00DD600D /* SubtitleFormat.swift in Sources */, E19D41B02BF2B7540082B8B2 /* URLSessionConfiguration.swift in Sources */, E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, @@ -4765,6 +5002,7 @@ E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, E1CB75752C80EAFA00217C76 /* ArrayBuilder.swift in Sources */, E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */, + 4E872AC62CB86308008C17BC /* DevicesViewModel.swift in Sources */, E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */, E18ACA922A15A32F00BB4F35 /* (null) in Sources */, E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */, @@ -4799,16 +5037,23 @@ E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */, E1A8FDEC2C0574A800D0A51C /* ListRow.swift in Sources */, 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */, + 4E9DB3F82CB796B100D36A26 /* LastRunSection.swift in Sources */, E1DD55372B6EE533007501C0 /* Task.swift in Sources */, E1ED7FE02CAA685900ACB6E3 /* ServerLogsView.swift in Sources */, + 4E9DB3E62CB7023900D36A26 /* ChevronInputButton.swift in Sources */, E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */, E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, + 4EA556B12CB48BB600F71E7A /* AddTaskTriggerView.swift in Sources */, + 4EA556C32CB4A26100F71E7A /* TimeLimitSection.swift in Sources */, + 4E3A2BE72CBC1E7B00B241A7 /* ActiveSessionIndicator.swift in Sources */, + 4EA556B22CB48BB600F71E7A /* TriggerRow.swift in Sources */, E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, + 4E9DB3E82CB726DF00D36A26 /* ServerTicks.swift in Sources */, C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, @@ -4849,6 +5094,7 @@ E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, E133328D2953AE4B00EE76AB /* CircularProgressView.swift in Sources */, E178B0762BE435D70023651B /* HourMinutePicker.swift in Sources */, + 4E24D5DF2CBCBED7000FD1C0 /* UserProfileView.swift in Sources */, E12F038C28F8B0B100976CC3 /* EdgeInsets.swift in Sources */, 6267B3D626710B8900A7371D /* Collection.swift in Sources */, E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */, @@ -4871,6 +5117,7 @@ E1A1528528FD191A00600579 /* TextPair.swift in Sources */, 6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */, E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, + 4E9DB3FE2CB7972A00D36A26 /* TriggersSection.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */, E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */, @@ -4880,6 +5127,7 @@ C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */, 4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */, E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, + 4EA556BD2CB4A21500F71E7A /* DayOfWeekSection.swift in Sources */, E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, E43918662AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */, E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */, @@ -4919,6 +5167,7 @@ E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */, 6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */, E10B1ECA2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */, + 4E9DB3FA2CB796D000D36A26 /* LastErrorSection.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */, 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */, E18E01E3288747230022598C /* CompactPortraitScrollView.swift in Sources */, @@ -5004,6 +5253,7 @@ E1CB75802C80F28F00217C76 /* SubtitleProfile.swift in Sources */, E1DD20412BE1EB8C00C0DE51 /* AddUserButton.swift in Sources */, E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */, + 4E59E92A2CB62E2700FA28E1 /* DayOfWeek.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, E14EA15E2BF6F72900DE757A /* PhotoPicker.swift in Sources */, E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */, @@ -5035,10 +5285,12 @@ E18E01F1288747230022598C /* PlayButton.swift in Sources */, E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */, E1F5CF092CB0A04500607465 /* Text.swift in Sources */, + 4EA556BF2CB4A22C00F71E7A /* TimeSection.swift in Sources */, 4E182C9F2C94A1E000FBEFD5 /* ScheduledTaskButton.swift in Sources */, E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */, E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */, + 4E24D5E12CBCBED7000FD1C0 /* UserAdministrationButton.swift in Sources */, E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */, 62E1DCC3273CE19800C9AE76 /* URL.swift in Sources */, E11BDF7A2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */, @@ -5046,10 +5298,12 @@ 4EC1C8692C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift in Sources */, E18E01E6288747230022598C /* CollectionItemView.swift in Sources */, E15D4F0A2B1BD88900442DB8 /* Edge.swift in Sources */, + 4E24D5C12CBC44F4000FD1C0 /* AdminDashboardCoordinator.swift in Sources */, 6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */, E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */, E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, + 4EA556B52CB48D1600F71E7A /* TaskTriggerType.swift in Sources */, E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, @@ -5058,6 +5312,7 @@ BD3957752C112A330078CEF8 /* ButtonSection.swift in Sources */, E1ED7FE32CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */, E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */, + 4E24D5E32CBCC9E3000FD1C0 /* UserFunctionButton.swift in Sources */, E13DD3F92717E961009D4DAF /* SelectUserViewModel.swift in Sources */, E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */, E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, @@ -5080,7 +5335,7 @@ E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */, E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */, - 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */, + 4E63B9FA2C8A5BEF00C25378 /* AdminDashboardView.swift in Sources */, E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */, 4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */, E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */, @@ -5088,6 +5343,7 @@ DFB7C3DF2C7AA43A00CE7CDC /* UserSignInState.swift in Sources */, E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */, 4E16FD572C01A32700110147 /* LetterPickerOrientation.swift in Sources */, + 4E24D5E02CBCBED7000FD1C0 /* UserAdministrationRow.swift in Sources */, E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */, 5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */, E13DD4022717EE79009D4DAF /* SelectUserCoordinator.swift in Sources */, @@ -5428,7 +5684,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 78; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -5444,7 +5700,7 @@ ); MARKETING_VERSION = 1.0.0; OTHER_CFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; @@ -5468,7 +5724,7 @@ CURRENT_PROJECT_VERSION = 78; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -5484,7 +5740,7 @@ ); MARKETING_VERSION = 1.0.0; OTHER_CFLAGS = ""; - PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin; + PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTS_MACCATALYST = NO; diff --git a/Swiftfin/Components/ActiveSessionIndicator.swift b/Swiftfin/Components/ActiveSessionIndicator.swift new file mode 100644 index 000000000..36063f209 --- /dev/null +++ b/Swiftfin/Components/ActiveSessionIndicator.swift @@ -0,0 +1,164 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// TODO: When selected, this crams together in a weird way + +struct ActiveSessionIndicator: View { + + @ObservedObject + var viewModel = ActiveSessionsViewModel() + + let action: () -> Void + + // MARK: - View Model Update Timer + + private let timer = Timer.publish(every: 60, on: .main, in: .common) + .autoconnect() + + // MARK: - Spinner States + + @State + private var isSpinning = false + @State + private var showSpinner = false + + // MARK: - Session States + + var activeSessions: Bool { + !viewModel.activeSessions.isEmpty + } + + var activeSessionsCount: Int { + viewModel.activeSessions.count + } + + // MARK: - Initializer + + init(action: @escaping () -> Void) { + self.action = action + self.viewModel.send(.getSessions) + } + + // MARK: - Body + + var body: some View { + Button(action: action) { + contentView + .onReceive(timer) { _ in + viewModel.send(.getSessions) + } + } + } + + // MARK: - Content View + + var contentView: some View { + switch viewModel.state { + case .content, .initial: + AnyView(sessionsView) + default: + AnyView(errorView) + } + } + + // MARK: - Sessions View + + var sessionsView: some View { + HStack(alignment: .bottom) { + if activeSessions { + counterView + .offset(x: 5) + } + ZStack { + imageView + loadingSpinner + } + .onChange(of: viewModel.backgroundStates) { newState in + if newState.contains(.gettingSessions) { + showSpinner = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + if !viewModel.backgroundStates.contains(.gettingSessions) { + showSpinner = false + } + } + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + showSpinner = false + } + } + } + } + } + + // MARK: - Image View + + var imageView: some View { + Image(systemName: "waveform.path.ecg") + .resizable() + .scaledToFit() + .padding(4) + .frame(width: 25, height: 25) + // TODO: Should this be a foregroundStyle? If so, which one? Potential + // issue if the AccentColor is too Light/Dark clashing with .primary + .foregroundColor(.primary) + .background( + Circle() + .fill(activeSessions ? Color.accentColor : .secondary) + ) + } + + // MARK: - Error View + + var errorView: some View { + Image(systemName: "exclamationmark.triangle") + .resizable() + .scaledToFit() + .padding(4) + .frame(width: 25, height: 25) + // TODO: Should this be a foregroundStyle? If so, which one? Potential + // issue if the AccentColor is too Light/Dark clashing with .primary + .foregroundColor(.primary) + .background( + Circle() + .fill(activeSessions ? Color.accentColor : .secondary) + ) + } + + // MARK: - Loading Spinner View + + var loadingSpinner: some View { + Circle() + .trim(from: 0.25, to: 0.75) + .stroke(showSpinner ? Color.accentColor : Color.clear, lineWidth: 3) + .frame(width: 35, height: 35) + .rotationEffect( + Angle(degrees: isSpinning ? 360 : 0) + ) + .animation( + .linear(duration: 1.5) + .repeatForever(autoreverses: false), + value: isSpinning + ) + .onAppear { + isSpinning = true + } + .onDisappear { + isSpinning = false + } + } + + // MARK: - Counter View + + var counterView: some View { + Text("\(activeSessionsCount)") + .padding(0) + .foregroundStyle(activeSessions ? Color.accentColor : .secondary) + } +} diff --git a/Swiftfin/Components/ChevronInputButton.swift b/Swiftfin/Components/ChevronInputButton.swift new file mode 100644 index 000000000..c5bd7ee10 --- /dev/null +++ b/Swiftfin/Components/ChevronInputButton.swift @@ -0,0 +1,151 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct ChevronInputButton: View where Content: View { + + @State + private var isSelected = false + + private let title: Text + private let subtitle: Text? + private var leadingView: () -> any View + private let menu: () -> Content + private let onSave: (() -> Void)? + private let onCancel: (() -> Void)? + private let description: String? + + // MARK: - Initializer: String Title, String Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + title: String, + subtitle: String, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = Text(title) + self.subtitle = Text(subtitle) + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: String Title, Text Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + title: String, + subtitleText: Text, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = Text(title) + self.subtitle = subtitleText + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: Text Title, String Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + titleText: Text, + subtitle: String, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = titleText + self.subtitle = Text(subtitle) + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Initializer: Text Title, Text Subtitle, Optional Description, and Menu / Optional Save/Cancel Actions + + init( + titleText: Text, + subtitleText: Text, + description: String? = nil, + @ViewBuilder menu: @escaping () -> Content, + onSave: (() -> Void)? = nil, + onCancel: (() -> Void)? = nil + ) { + self.title = titleText + self.subtitle = subtitleText + self.description = description + self.leadingView = { EmptyView() } + self.menu = menu + self.onSave = onSave + self.onCancel = onCancel + } + + // MARK: - Leading View Customization + + func leadingView(@ViewBuilder _ content: @escaping () -> any View) -> Self { + var copy = self + copy.leadingView = content + return copy + } + + // MARK: - Body + + var body: some View { + Button { + isSelected = true + } label: { + HStack { + leadingView() + .eraseToAnyView() + + title + .foregroundColor(.primary) + + Spacer() + + if let subtitle { + subtitle + .foregroundColor(.secondary) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .alert(title, isPresented: $isSelected) { + menu() + + Button(L10n.save) { + onSave?() + isSelected = false + } + Button(L10n.cancel, role: .cancel) { + onCancel?() + isSelected = false + } + } message: { + if let description = description { + Text(description) + } + } + } +} diff --git a/Swiftfin/Components/DeviceTypes.swift b/Swiftfin/Components/DeviceTypes.swift new file mode 100644 index 000000000..8dbd6da7d --- /dev/null +++ b/Swiftfin/Components/DeviceTypes.swift @@ -0,0 +1,176 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +enum DeviceType: String, Displayable, SystemImageable, Codable, CaseIterable { + case android = "Device-android" + case apple = "Device-apple" + case chrome = "Device-browser-chrome" + case edge = "Device-browser-edge" + case edgechromium = "Device-browser-edgechromium" + case finamp = "Device-finamp" + case firefox = "Device-browser-firefox" + case homeAssistant = "Device-homeassistant" + case html5 = "Device-html5" + case kodi = "Device-kodi" + case msie = "Device-browser-msie" + case opera = "Device-browser-opera" + case playstation = "Device-playstation" + case roku = "Device-roku" + case safari = "Device-browser-safari" + case samsungtv = "Device-samsungtv" + case windows = "Device-windows" + case xbox = "Device-xbox" + case other = "Device-other" + + // MARK: - Initialize the Client + + init(client: String?, deviceName: String?) { + switch client { + case "Samsung Smart TV": + self = .samsungtv + case "Xbox One": + self = .xbox + case "Sony PS4": + self = .playstation + case "Kodi", "Kodi JellyCon": + self = .kodi + case "Jellyfin Android", "AndroidTV", "Android TV": + self = .android + case "Jellyfin Mobile (iOS)", "Jellyfin Mobile (iPadOS)", "Jellyfin iOS", "Jellyfin iPadOS", "Jellyfin tvOS", "Swiftfin iPadOS", + "Swiftfin iOS", "Swiftfin tvOS", "Infuse", "Infuse-Direct", "Infuse-Library": + self = .apple + case "Home Assistant": + self = .homeAssistant + case "Jellyfin Roku": + self = .roku + case "Finamp": + self = .finamp + case "Jellyfin Web", "Jellyfin Web (Vue)": + self = DeviceType(webBrowser: deviceName) + default: + self = .other + } + } + + // MARK: - Initialize the Browser if Jellyfin-Web + + private init(webBrowser: String?) { + switch webBrowser { + case "Opera", "Opera TV", "Opera Android": + self = .opera + case "Chrome", "Chrome Android": + self = .chrome + case "Firefox", "Firefox Android": + self = .firefox + case "Safari", "Safari iPad", "Safari iPhone": + self = .safari + case "Edge Chromium", "Edge Chromium Android", "Edge Chromium iPad", "Edge Chromium iPhone": + self = .edgechromium + case "Edge": + self = .edge + case "Internet Explorer": + self = .msie + default: + self = .html5 + } + } + + // MARK: - Client Image + + var systemImage: String { + rawValue + } + + // MARK: - Client Color + + var clientColor: Color { + switch self { + case .samsungtv: + return Color(red: 0.0, green: 0.44, blue: 0.74) // Samsung Blue + case .xbox: + return Color(red: 0.0, green: 0.5, blue: 0.0) // Xbox Green + case .playstation: + return Color(red: 0.0, green: 0.32, blue: 0.65) // PlayStation Blue + case .kodi: + return Color(red: 0.0, green: 0.58, blue: 0.83) // Kodi Blue + case .android: + return Color(red: 0.18, green: 0.8, blue: 0.44) // Android Green + case .apple: + return Color(red: 0.35, green: 0.35, blue: 0.35) // Apple Gray + case .homeAssistant: + return Color(red: 0.0, green: 0.55, blue: 0.87) // Home Assistant Blue + case .roku: + return Color(red: 0.31, green: 0.09, blue: 0.55) // Roku Purple + case .finamp: + return Color(red: 0.61, green: 0.32, blue: 0.88) // Finamp Purple + case .chrome: + return Color(red: 0.98, green: 0.75, blue: 0.18) // Chrome Yellow + case .firefox: + return Color(red: 1.0, green: 0.33, blue: 0.0) // Firefox Orange + case .safari: + return Color(red: 0.0, green: 0.48, blue: 1.0) // Safari Blue + case .edgechromium: + return Color(red: 0.0, green: 0.45, blue: 0.75) // Edge Chromium Blue + case .edge: + return Color(red: 0.19, green: 0.31, blue: 0.51) // Edge Gray + case .msie: + return Color(red: 0.0, green: 0.53, blue: 1.0) // Internet Explorer Blue + case .opera: + return Color(red: 1.0, green: 0.0, blue: 0.0) // Opera Red + default: + return Color.black + } + } + + // MARK: - Client Title + + var displayTitle: String { + switch self { + case .android: + return "Android" + case .apple: + return "Apple" + case .chrome: + return "Chrome" + case .edge: + return "Edge" + case .edgechromium: + return "Edge Chromium" + case .finamp: + return "Finamp" + case .firefox: + return "Firefox" + case .homeAssistant: + return "Home Assistant" + case .html5: + return "HTML5" + case .kodi: + return "Kodi" + case .msie: + return "Internet Explorer" + case .opera: + return "Opera" + case .playstation: + return "PlayStation" + case .roku: + return "Roku" + case .safari: + return "Safari" + case .samsungtv: + return "Samsung TV" + case .windows: + return "Windows" + case .xbox: + return "Xbox" + case .other: + return "Other" + } + } +} diff --git a/Swiftfin/Components/ServerTicks.swift b/Swiftfin/Components/ServerTicks.swift new file mode 100644 index 000000000..2f9b22fbc --- /dev/null +++ b/Swiftfin/Components/ServerTicks.swift @@ -0,0 +1,84 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation + +struct ServerTicks { + private var ticksValue: Int + + // MARK: - Conversion Constants + + private let ticksPerSecond = 10_000_000 + private let ticksPerMinute = 600_000_000 + private let ticksPerHour = 36_000_000_000 + private let ticksPerDay = 864_000_000_000 + + // MARK: - Initializers + + init(ticks: Int? = nil) { + self.ticksValue = ticks ?? 0 + } + + init(seconds: Int? = nil) { + self.ticksValue = (seconds ?? 0) * ticksPerSecond + } + + init(minutes: Int? = nil) { + self.ticksValue = (minutes ?? 0) * ticksPerMinute + } + + init(hours: Int? = nil) { + self.ticksValue = (hours ?? 0) * ticksPerHour + } + + init(days: Int? = nil) { + self.ticksValue = (days ?? 0) * ticksPerDay + } + + init(timeInterval: TimeInterval? = nil) { + self.ticksValue = Int((timeInterval ?? 0) * Double(ticksPerSecond)) + } + + init(date: Date) { + let components = Calendar.current.dateComponents([.hour, .minute], from: date) + let totalSeconds = TimeInterval((components.hour ?? 0) * 3600 + (components.minute ?? 0) * 60) + self.ticksValue = Int(totalSeconds * 10_000_000) + } + + // MARK: - Computed Properties + + var ticks: Int { + ticksValue + } + + var seconds: TimeInterval { + TimeInterval(ticksValue) / Double(ticksPerSecond) + } + + var minutes: TimeInterval { + TimeInterval(ticksValue) / Double(ticksPerMinute) + } + + var hours: TimeInterval { + TimeInterval(ticksValue) / Double(ticksPerHour) + } + + var days: TimeInterval { + TimeInterval(ticksValue) / Double(ticksPerDay) + } + + var date: Date { + let totalSeconds = TimeInterval(ticksValue) / 10_000_000 + let hours = Int(totalSeconds) / 3600 + let minutes = (Int(totalSeconds) % 3600) / 60 + var components = DateComponents() + components.hour = hours + components.minute = minutes + return Calendar.current.date(from: components) ?? Date() + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json new file mode 100644 index 000000000..400c04b35 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "chrome.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg new file mode 100644 index 000000000..fab308dc2 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg @@ -0,0 +1 @@ +Google Chrome icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json new file mode 100644 index 000000000..84879e400 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "edge.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg new file mode 100644 index 000000000..8a552924d --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg @@ -0,0 +1 @@ +Microsoft Edge icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json new file mode 100644 index 000000000..0585791bc --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "edgechromium.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg new file mode 100644 index 000000000..14d68a5d4 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg @@ -0,0 +1 @@ +Microsoft Edge icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json new file mode 100644 index 000000000..475295ee0 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "firefox.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg new file mode 100644 index 000000000..7f468b3f0 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg @@ -0,0 +1 @@ +Mozilla Firefox icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json new file mode 100644 index 000000000..2b9cb71f9 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "msie.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg new file mode 100644 index 000000000..f5b362d7c --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg @@ -0,0 +1 @@ +Internet Explorer icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json new file mode 100644 index 000000000..01b1e08b3 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "opera.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg new file mode 100644 index 000000000..dd57f924a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg @@ -0,0 +1 @@ +Opera icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json new file mode 100644 index 000000000..d8d4b878c --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "safari.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg new file mode 100644 index 000000000..12abbb95f --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg @@ -0,0 +1 @@ +safari icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/Contents.json new file mode 100644 index 000000000..e5cf7bcf6 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "android.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/android.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/android.svg new file mode 100644 index 000000000..24edc8bbf --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-android.imageset/android.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/Contents.json new file mode 100644 index 000000000..e551ee4ef --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "apple.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/apple.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/apple.svg new file mode 100644 index 000000000..4477a4525 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-apple.imageset/apple.svg @@ -0,0 +1 @@ +Apple diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/Contents.json new file mode 100644 index 000000000..dc9dc7f86 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "finamp.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/finamp.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/finamp.svg new file mode 100644 index 000000000..8bd3a90ca --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-finamp.imageset/finamp.svg @@ -0,0 +1,7 @@ + + Finamp icon + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/Contents.json new file mode 100644 index 000000000..dce2be9a0 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "kodi.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/kodi.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/kodi.svg new file mode 100644 index 000000000..3618149b1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-kodi.imageset/kodi.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/Contents.json new file mode 100644 index 000000000..1fc27e4ec --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "playstation.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/playstation.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/playstation.svg new file mode 100644 index 000000000..c6595340e --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-playstation.imageset/playstation.svg @@ -0,0 +1 @@ +PlayStation icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/Contents.json new file mode 100644 index 000000000..b71427a78 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "roku.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/roku.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/roku.svg new file mode 100644 index 000000000..eb1e621b5 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-roku.imageset/roku.svg @@ -0,0 +1,7 @@ + + Roku icon + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/Contents.json new file mode 100644 index 000000000..6c174e7ac --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "samsungtv.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/samsungtv.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/samsungtv.svg new file mode 100644 index 000000000..afdd19e24 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-samsungtv.imageset/samsungtv.svg @@ -0,0 +1 @@ +Samsung icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/Contents.json new file mode 100644 index 000000000..6bb53f6a1 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "windows.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/windows.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/windows.svg new file mode 100644 index 000000000..531e72e1d --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-windows.imageset/windows.svg @@ -0,0 +1 @@ +Windows icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/Contents.json new file mode 100644 index 000000000..5430fc8e2 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "xbox.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/xbox.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/xbox.svg new file mode 100644 index 000000000..640dd34a5 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Clients/Device-xbox.imageset/xbox.svg @@ -0,0 +1 @@ +Xbox icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/Contents.json new file mode 100644 index 000000000..774270372 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "home-assistant.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/home-assistant.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/home-assistant.svg new file mode 100644 index 000000000..a34be98de --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-homeassistant.imageset/home-assistant.svg @@ -0,0 +1 @@ + diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/Contents.json new file mode 100644 index 000000000..79ef7e746 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "html5.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/html5.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/html5.svg new file mode 100644 index 000000000..63704799c --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-html5.imageset/html5.svg @@ -0,0 +1 @@ +HTML5 icon diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/Contents.json b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/Contents.json new file mode 100644 index 000000000..e7592de30 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "other.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/other.svg b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/other.svg new file mode 100644 index 000000000..91e1d9e25 --- /dev/null +++ b/Swiftfin/Resources/Assets.xcassets/DeviceIcons/Other/Device-other.imageset/other.svg @@ -0,0 +1 @@ + diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/StreamSection.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/Components/StreamSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/StreamSection.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/Components/StreamSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionsView/ActiveSessionsView.swift similarity index 97% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionsView/ActiveSessionsView.swift index 49507b85b..9c7e21eed 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessionsView/ActiveSessionsView.swift @@ -16,7 +16,7 @@ import SwiftUI struct ActiveSessionsView: View { @EnvironmentObject - private var router: SettingsCoordinator.Router + private var router: AdminDashboardCoordinator.Router @StateObject private var viewModel = ActiveSessionsViewModel() diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift similarity index 65% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift index 52bb1ca40..ebd8e2168 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift @@ -10,8 +10,6 @@ import Defaults import JellyfinAPI import SwiftUI -// TODO: inactive session device image - extension ActiveSessionsView { struct ActiveSessionRow: View { @@ -24,7 +22,6 @@ extension ActiveSessionsView { private let onSelect: () -> Void - // parent list won't show row if value is nil anyways private var session: SessionInfo { box.value ?? .init() } @@ -38,29 +35,47 @@ extension ActiveSessionsView { private var rowLeading: some View { // TODO: better handling for different poster types Group { - if session.nowPlayingItem?.type == .audio { + switch session.nowPlayingItem { + case .none: ZStack { - Color.clear - - ImageView(session.nowPlayingItem?.squareImageSources(maxWidth: 60) ?? []) - .failure { - SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) - } - } - .squarePosterStyle() - } else { - ZStack { - Color.clear - - ImageView(session.nowPlayingItem?.portraitImageSources(maxWidth: 60) ?? []) - .failure { - SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) - } + DeviceType( + client: session.client, + deviceName: session.deviceName + ).clientColor + + Image(DeviceType(client: session.client, deviceName: session.deviceName).systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40) } .posterStyle(.portrait) + default: + if session.nowPlayingItem?.type == .audio { + ZStack { + Color.clear + + ImageView(session.nowPlayingItem?.squareImageSources(maxWidth: 60) ?? []) + .failure { + SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) + } + } + .squarePosterStyle() + .frame(width: 60, height: 60) + } else { + ZStack { + Color.clear + + ImageView(session.nowPlayingItem?.portraitImageSources(maxWidth: 60) ?? []) + .failure { + SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) + } + } + .posterStyle(.portrait) + .frame(width: 60, height: 90) + } } } - .frame(width: 60) + .frame(width: 60, height: 90) .posterShadow() .padding(.vertical, 8) } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ProgressSection.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessionsView/Components/Sections/ProgressSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ProgressSection.swift rename to Swiftfin/Views/AdminDashboardView/ActiveSessionsView/Components/Sections/ProgressSection.swift diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift new file mode 100644 index 000000000..859f67917 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift @@ -0,0 +1,100 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct AddTaskTriggerView: View { + + @ObservedObject + var observer: ServerTaskObserver + + @State + private var taskTriggerInfo: TaskTriggerInfo + + private let emptyTaskTriggerInfo: TaskTriggerInfo + + @Environment(\.dismiss) + private var dismiss + + @State + private var isPresentingNotSaved = false + + // MARK: - Default Trigger Values + + static let defaultTimeOfDayTicks = 0 + static let defaultDayOfWeek: DayOfWeek = .sunday + static let defaultIntervalTicks = 36_000_000_000 + + // MARK: - Unsaved Changes Validation + + private var hasUnsavedChanges: Bool { + taskTriggerInfo != emptyTaskTriggerInfo + } + + // MARK: - Init + + init(observer: ServerTaskObserver) { + self.observer = observer + + let newTrigger = TaskTriggerInfo( + dayOfWeek: nil, + intervalTicks: nil, + maxRuntimeTicks: nil, + timeOfDayTicks: nil, + type: TaskTriggerType.startup.rawValue + ) + + _taskTriggerInfo = State(initialValue: newTrigger) + self.emptyTaskTriggerInfo = newTrigger + } + + // MARK: - Body + + var body: some View { + Form { + TriggerTypeSection(taskTriggerInfo: $taskTriggerInfo) + + DayOfWeekSection(taskTriggerInfo: $taskTriggerInfo) + + TimeSection(taskTriggerInfo: $taskTriggerInfo) + + IntervalSection(taskTriggerInfo: $taskTriggerInfo) + + TimeLimitSection(taskTriggerInfo: $taskTriggerInfo) + } + .interactiveDismissDisabled(true) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + if hasUnsavedChanges { + isPresentingNotSaved = true + } else { + dismiss() + } + } + .navigationTitle(L10n.addTaskTrigger) + .topBarTrailing { + Button(L10n.save) { + + UIDevice.impact(.light) + + observer.send(.addTrigger(taskTriggerInfo)) + dismiss() + } + .buttonStyle(.toolbarPill) + } + .alert(L10n.unsavedChangesMessage, isPresented: $isPresentingNotSaved) { + Button(L10n.close, role: .destructive) { + dismiss() + } + Button(L10n.cancel, role: .cancel) { + isPresentingNotSaved = false + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift new file mode 100644 index 000000000..deb153e6f --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/DayOfWeekSection.swift @@ -0,0 +1,37 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct DayOfWeekSection: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + // MARK: - Body + + var body: some View { + if taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { + Picker( + L10n.dayOfWeek, + selection: Binding( + get: { taskTriggerInfo.dayOfWeek ?? defaultDayOfWeek }, + set: { taskTriggerInfo.dayOfWeek = $0 } + ) + ) { + ForEach(DayOfWeek.allCases, id: \.self) { day in + Text(day.displayTitle ?? L10n.unknown).tag(day) + } + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift new file mode 100644 index 000000000..8ee9e6c04 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/IntervalSection.swift @@ -0,0 +1,37 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct IntervalSection: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + // MARK: - Body + + var body: some View { + if taskTriggerInfo.type == TaskTriggerType.interval.rawValue { + Picker( + L10n.every, + selection: Binding( + get: { taskTriggerInfo.intervalTicks ?? defaultIntervalTicks }, + set: { taskTriggerInfo.intervalTicks = $0 } + ) + ) { + ForEach(Array(stride(from: 900, to: 86400 + 1, by: 900)), id: \.self) { interval in + Text(TimeInterval(interval).formatted(.hourMinute)).tag(ServerTicks(seconds: interval).ticks) + } + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift new file mode 100644 index 000000000..b122895eb --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeLimitSection.swift @@ -0,0 +1,66 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct TimeLimitSection: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + @State + var tempTimeLimit: Int? + + // MARK: - Init + + init(taskTriggerInfo: Binding) { + self._taskTriggerInfo = taskTriggerInfo + _tempTimeLimit = State(initialValue: taskTriggerInfo.wrappedValue.maxRuntimeTicks) + } + + // MARK: - Body + + var body: some View { + Section { + ChevronInputButton( + title: L10n.timeLimit, + subtitle: subtitleString, + description: L10n.taskTriggerTimeLimit + ) { + TextField( + L10n.timeLimit, + value: $tempTimeLimit, + format: .number + ) + .keyboardType(.numberPad) + } onSave: { + if tempTimeLimit != nil && tempTimeLimit != 0 { + taskTriggerInfo.maxRuntimeTicks = ServerTicks(hours: tempTimeLimit).ticks + } else { + taskTriggerInfo.maxRuntimeTicks = nil + } + } onCancel: { + tempTimeLimit = taskTriggerInfo.maxRuntimeTicks + } + } + } + + // MARK: - Create Subtitle String + + private var subtitleString: String { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + ServerTicks(ticks: maxRuntimeTicks).seconds.formatted(.hourMinute) + } else { + L10n.disabled + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift new file mode 100644 index 000000000..0f32eafc0 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TimeSection.swift @@ -0,0 +1,38 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct TimeSection: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + if taskTriggerInfo.type == TaskTriggerType.daily.rawValue || taskTriggerInfo.type == TaskTriggerType.weekly.rawValue { + DatePicker( + L10n.time, + selection: Binding( + get: { + ServerTicks( + ticks: taskTriggerInfo.timeOfDayTicks ?? defaultTimeOfDayTicks + ).date + }, + set: { date in + taskTriggerInfo.timeOfDayTicks = ServerTicks(date: date).ticks + } + ), + displayedComponents: .hourAndMinute + ) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift new file mode 100644 index 000000000..cf56666b5 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/AddTaskTriggerView/Components/Sections/TriggerTypeSection.swift @@ -0,0 +1,70 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct TriggerTypeSection: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + Picker( + L10n.triggerType, + selection: Binding( + get: { + TaskTriggerType(rawValue: taskTriggerInfo.type ?? "") + }, + set: { newValue in + if taskTriggerInfo.type != newValue?.rawValue { + resetValuesForNewType(newType: newValue) + } + } + ) + ) { + ForEach(TaskTriggerType.allCases, id: \.self) { type in + Text(type.displayTitle) + .tag(type as TaskTriggerType?) + } + } + } + + private func resetValuesForNewType(newType: TaskTriggerType?) { + taskTriggerInfo.type = newType?.rawValue + let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks + + switch newType { + case .daily: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + case .weekly: + taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks + taskTriggerInfo.dayOfWeek = defaultDayOfWeek + taskTriggerInfo.intervalTicks = nil + case .interval: + taskTriggerInfo.intervalTicks = defaultIntervalTicks + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + case .startup: + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + default: + taskTriggerInfo.timeOfDayTicks = nil + taskTriggerInfo.dayOfWeek = nil + taskTriggerInfo.intervalTicks = nil + } + + taskTriggerInfo.maxRuntimeTicks = maxRuntimeTicks + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift b/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift similarity index 66% rename from Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift rename to Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift index f939796b9..825e24a31 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift +++ b/Swiftfin/Views/AdminDashboardView/AdminDashboardView.swift @@ -8,10 +8,10 @@ import SwiftUI -struct UserDashboardView: View { +struct AdminDashboardView: View { @EnvironmentObject - private var router: SettingsCoordinator.Router + private var router: AdminDashboardCoordinator.Router // MARK: - Body @@ -30,6 +30,11 @@ struct UserDashboardView: View { Section(L10n.advanced) { + ChevronButton(L10n.allDevices) + .onSelect { + router.route(to: \.devices) + } + ChevronButton(L10n.logs) .onSelect { router.route(to: \.serverLogs) @@ -39,8 +44,17 @@ struct UserDashboardView: View { .onSelect { router.route(to: \.tasks) } + + ChevronButton(L10n.users) + .onSelect { + router.route(to: \.users) + } } } .navigationTitle(L10n.dashboard) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + router.dismissCoordinator() + } } } diff --git a/Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift b/Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift new file mode 100644 index 000000000..5e9bb7f72 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/DevicesView/Components/DeviceRow.swift @@ -0,0 +1,118 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension DevicesView { + + struct DeviceRow: View { + + @CurrentDate + private var currentDate: Date + + @ObservedObject + private var box: BindingBox + + let onSelect: () -> Void + let onDelete: () -> Void + + // MARK: - Device Mapping + + private var device: DeviceInfo { + box.value ?? .init() + } + + // MARK: - Initializer + + init( + box: BindingBox, + onSelect editAction: @escaping () -> Void, + onDelete deleteAction: @escaping () -> Void + ) { + self.box = box + self.onSelect = editAction + self.onDelete = deleteAction + } + + // MARK: - Body + + var body: some View { + ListRow(insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding)) { + rowLeading + } content: { + deviceDetails + } + .onSelect(perform: onSelect) + .swipeActions { + Button { + onDelete() + } label: { + Label(L10n.delete, systemImage: "trash") + } + .tint(.red) + } + } + + // MARK: - Row Leading Image + + @ViewBuilder + private var rowLeading: some View { + ZStack { + DeviceType( + client: device.appName, + deviceName: device.name + ).clientColor + + Image(DeviceType( + client: device.appName, + deviceName: device.name + ).systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40) + } + .posterStyle(.portrait) + .frame(width: 60) + .posterShadow() + .padding(.vertical, 8) + } + + // MARK: - Row Device Details + + @ViewBuilder + private var deviceDetails: some View { + VStack(alignment: .leading) { + // TODO: Change to (CustomName ?? DeviceName) when available + Text(device.name ?? L10n.unknown) + .font(.headline) + + Text(device.lastUserName ?? L10n.unknown) + + TextPairView( + leading: device.appName ?? L10n.unknown, + trailing: device.appVersion ?? .emptyDash + ) + + TextPairView( + L10n.lastSeen, + value: { + if let dateLastActivity = device.dateLastActivity { + Text(dateLastActivity, format: .relative(presentation: .numeric, unitsStyle: .narrow)) + } else { + Text(L10n.never) + } + }() + ) + .id(currentDate) + .monospacedDigit() + } + .font(.subheadline) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift b/Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift new file mode 100644 index 000000000..a0044a74a --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/DevicesView/DevicesView.swift @@ -0,0 +1,201 @@ +// +// 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 DevicesView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @StateObject + private var viewModel = DevicesViewModel() + + @State + private var isPresentingDeleteAllConfirmation = false + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingRenameAlert = false + @State + private var isPresentingSelfDeleteError = false + @State + private var selectedDevice: DeviceInfo? + @State + private var temporaryDeviceName: String = "" + @State + private var deviceToDelete: String? + + // MARK: - Body + + var body: some View { + Group { + contentView + } + .navigationTitle(L10n.allDevices) + .onFirstAppear { + viewModel.send(.getDevices) + } + .topBarTrailing { + navigationBarView + } + .confirmationDialog( + L10n.deleteAllDevices, + isPresented: $isPresentingDeleteAllConfirmation, + titleVisibility: .visible + ) { + deleteAllDevicesConfirmationActions + } message: { + Text(L10n.deleteAllDevicesWarning) + } + .confirmationDialog( + L10n.deleteDevice, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteDeviceConfirmationActions + } message: { + Text(L10n.deleteDeviceWarning) + } + .alert(isPresented: $isPresentingSelfDeleteError) { + deletionFailureAlert + } + .alert(L10n.customDeviceName, isPresented: $isPresentingRenameAlert) { + customDeviceNameAlert + } message: { + Text(L10n.enterCustomDeviceName) + } + } + + // MARK: - Content View + + private var contentView: some View { + Group { + switch viewModel.state { + case .content: + if viewModel.devices.isEmpty { + Text(L10n.none) + } else { + deviceListView + } + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.getDevices) + } + case .initial: + DelayedProgressView() + } + } + } + + // MARK: - Navigation Bar Content + + private var navigationBarView: some View { + Group { + if viewModel.backgroundStates.contains(.gettingDevices) { + ProgressView() + } else { + Button(L10n.deleteAll, role: .destructive) { + isPresentingDeleteAllConfirmation = true + UIDevice.impact(.light) + } + .buttonStyle(.toolbarPill) + .disabled(viewModel.devices.isEmpty) + } + } + } + + // MARK: - Device List View + + private var deviceListView: some View { + List { + ListTitleSection( + L10n.devices, + description: L10n.allDevicesDescription + ) { + UIApplication.shared.open(URL(string: "https://jellyfin.org/docs/general/server/devices")!) + } + ForEach(Array(viewModel.devices.keys), id: \.self) { id in + if let deviceBox = viewModel.devices[id] { + DeviceRow(box: deviceBox) { + selectedDevice = deviceBox.value + temporaryDeviceName = selectedDevice?.name ?? "" + isPresentingRenameAlert = true + } onDelete: { + deviceToDelete = deviceBox.value?.id + selectedDevice = deviceBox.value + isPresentingDeleteConfirmation = true + } + } + } + } + } + + // MARK: - Delete All Devices Confirmation Actions + + private var deleteAllDevicesConfirmationActions: some View { + Group { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + viewModel.send(.deleteAllDevices) + } + } + } + + // MARK: - Delete Device Confirmation Actions + + private var deleteDeviceConfirmationActions: some View { + Group { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let deviceToDelete = deviceToDelete { + if deviceToDelete == viewModel.userSession.client.configuration.deviceID { + isPresentingSelfDeleteError = true + } else { + viewModel.send(.deleteDevice(id: deviceToDelete)) + } + } + } + } + } + + // MARK: - Rename Custom Device Name Alert + + private var customDeviceNameAlert: some View { + Group { + TextField(L10n.name, text: $temporaryDeviceName) + .keyboardType(.default) + + Button(L10n.save) { + if let deviceId = selectedDevice?.id { + viewModel.send(.setCustomName(id: deviceId, newName: temporaryDeviceName)) + } + isPresentingRenameAlert = false + } + + Button(L10n.cancel, role: .cancel) { + isPresentingRenameAlert = false + } + } + } + + // MARK: - Deletion Failure Alert + + private var deletionFailureAlert: Alert { + Alert( + title: Text(L10n.deleteDeviceFailed), + message: Text(L10n.deleteDeviceSelfDeletion(selectedDevice?.name ?? L10n.unknown)), + dismissButton: .default(Text(L10n.ok)) + ) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift new file mode 100644 index 000000000..0f726670c --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/CurrentRunningSection.swift @@ -0,0 +1,34 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension EditScheduledTaskView { + + struct CurrentRunningSection: View { + + var task: TaskInfo + + var body: some View { + Section(L10n.progress) { + if let status = task.state { + TextPairView(L10n.status, value: Text(status.displayTitle)) + } + + if let currentProgressPercentage = task.currentProgressPercentage { + TextPairView( + L10n.taskCompleted, + value: Text("\(currentProgressPercentage / 100, format: .percent.precision(.fractionLength(1)))") + ) + .monospacedDigit() + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift new file mode 100644 index 000000000..3b659a3d9 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/DetailsSection.swift @@ -0,0 +1,25 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension EditScheduledTaskView { + + struct DetailsSection: View { + + var category: String? + + var body: some View { + Section(L10n.details) { + if let category = category { + TextPairView(leading: L10n.category, trailing: category) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift new file mode 100644 index 000000000..73d4a8f9c --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastErrorSection.swift @@ -0,0 +1,29 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension EditScheduledTaskView { + + struct LastErrorSection: View { + + var lastExecutionResult: TaskResult + + var body: some View { + Section(L10n.errorDetails) { + if let errorMessage = lastExecutionResult.errorMessage { + Text(errorMessage) + } + if let longErrorMessage = lastExecutionResult.longErrorMessage { + Text(longErrorMessage) + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift new file mode 100644 index 000000000..dccb0245d --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/LastRunSection.swift @@ -0,0 +1,33 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension EditScheduledTaskView { + + struct LastRunSection: View { + + var lastExecutionResult: TaskResult + + var body: some View { + Section(L10n.lastRun) { + if let status = lastExecutionResult.status { + TextPairView(L10n.status, value: Text(status.displayTitle)) + } + if let endTimeUtc = lastExecutionResult.endTimeUtc { + TextPairView( + L10n.executed, + value: Text("\(endTimeUtc, format: .relative(presentation: .numeric, unitsStyle: .narrow))") + ) + .monospacedDigit() + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift new file mode 100644 index 000000000..1777fbcbb --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/Sections/TriggersSection.swift @@ -0,0 +1,47 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension EditScheduledTaskView { + + struct TriggersSection: View { + + var triggers: [TaskTriggerInfo]? + @Binding + var isPresentingDeleteConfirmation: Bool + @Binding + var selectedTrigger: TaskTriggerInfo? + var deleteAction: (TaskTriggerInfo) -> Void + var addAction: () -> Void + + var body: some View { + Section(L10n.triggers) { + if let triggers = triggers, !triggers.isEmpty { + ForEach(triggers, id: \.self) { trigger in + TriggerRow(taskTriggerInfo: trigger) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button { + selectedTrigger = trigger + isPresentingDeleteConfirmation = true + } label: { + Label(L10n.delete, systemImage: "trash") + } + .tint(.red) + } + } + } else { + Button(L10n.addTaskTrigger) { + addAction() + } + } + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/TriggerRow.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/TriggerRow.swift new file mode 100644 index 000000000..985c46b14 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/Components/TriggerRow.swift @@ -0,0 +1,95 @@ +// +// 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 JellyfinAPI +import Stinsen +import SwiftUI + +extension EditScheduledTaskView { + + struct TriggerRow: View { + + let taskTriggerInfo: TaskTriggerInfo + + // TODO: remove after `TaskTriggerType` is provided by SDK + + private var taskTriggerType: TaskTriggerType { + if let type = taskTriggerInfo.type { + return TaskTriggerType(rawValue: type)! + } else { + return .startup + } + } + + // MARK: - Body + + var body: some View { + HStack { + VStack(alignment: .leading) { + + Text(triggerDisplayText) + .fontWeight(.semibold) + + Group { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + Text( + L10n.timeLimitLabelWithHours( + ServerTicks(ticks: maxRuntimeTicks) + .seconds.formatted(.hourMinute) + ) + ) + } else { + Text(L10n.noRuntimeLimit) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: taskTriggerType.systemImage) + .foregroundStyle(.secondary) + } + } + + // MARK: - Trigger Display Text + + private var triggerDisplayText: String { + switch taskTriggerType { + case .daily: + if let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { + return L10n.itemAtItem( + taskTriggerType.displayTitle, + ServerTicks(ticks: timeOfDayTicks) + .date.formatted(date: .omitted, time: .shortened) + ) + } + case .weekly: + if let dayOfWeek = taskTriggerInfo.dayOfWeek, + let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks + { + return L10n.itemAtItem( + dayOfWeek.rawValue.capitalized, + ServerTicks(ticks: timeOfDayTicks) + .date.formatted(date: .omitted, time: .shortened) + ) + } + case .interval: + if let intervalTicks = taskTriggerInfo.intervalTicks { + return L10n.everyInterval( + ServerTicks(ticks: intervalTicks) + .seconds.formatted(.hourMinute) + ) + } + case .startup: + return taskTriggerType.displayTitle + } + return L10n.unknown + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift new file mode 100644 index 000000000..a3246a5af --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/EditScheduledTaskView/EditScheduledTaskView.swift @@ -0,0 +1,84 @@ +// +// 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 JellyfinAPI +import SwiftUI + +// TODO: observe running status +// - stop +// - run + +struct EditScheduledTaskView: View { + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @ObservedObject + var observer: ServerTaskObserver + + @State + private var isPresentingDeleteConfirmation = false + @State + private var selectedTrigger: TaskTriggerInfo? + + var body: some View { + List { + ListTitleSection( + observer.task.name ?? L10n.unknown, + description: observer.task.description + ) + + DetailsSection(category: observer.task.category) + + if let lastExecutionResult = observer.task.lastExecutionResult { + LastRunSection(lastExecutionResult: lastExecutionResult) + + if lastExecutionResult.errorMessage != nil { + LastErrorSection(lastExecutionResult: lastExecutionResult) + } + } + + if observer.task.state == .running || observer.task.state == .cancelling { + CurrentRunningSection(task: observer.task) + } + + TriggersSection( + triggers: observer.task.triggers, + isPresentingDeleteConfirmation: $isPresentingDeleteConfirmation, + selectedTrigger: $selectedTrigger, + deleteAction: { trigger in observer.send(.removeTrigger(trigger)) }, + addAction: { router.route(to: \.addScheduledTaskTrigger, observer) } + ) + } + .navigationTitle(L10n.task) + .topBarTrailing { + if observer.task.triggers?.isEmpty == false { + Button(L10n.add) { + UIDevice.impact(.light) + router.route(to: \.addScheduledTaskTrigger, observer) + } + .buttonStyle(.toolbarPill) + } + } + .confirmationDialog( + L10n.deleteTrigger, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let selectedTrigger = selectedTrigger { + observer.send(.removeTrigger(selectedTrigger)) + } + } + } message: { + Text(L10n.deleteTriggerConfirmationMessage) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift b/Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift similarity index 98% rename from Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift rename to Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift index 4c5f92b4f..de33816a6 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift +++ b/Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift @@ -18,7 +18,7 @@ extension ScheduledTasksView { private var currentDate: Date @EnvironmentObject - private var router: SettingsCoordinator.Router + private var router: AdminDashboardCoordinator.Router @ObservedObject var observer: ServerTaskObserver diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift b/Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift rename to Swiftfin/Views/AdminDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift b/Swiftfin/Views/AdminDashboardView/ScheduledTasksView/ScheduledTasksView.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift rename to Swiftfin/Views/AdminDashboardView/ScheduledTasksView/ScheduledTasksView.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift b/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift similarity index 90% rename from Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift rename to Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift index 376ecc25a..180a72cb0 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift @@ -24,6 +24,12 @@ struct ServerLogsView: View { @ViewBuilder private var contentView: some View { List { + ListTitleSection( + L10n.logs, + description: L10n.logsDescription + ) { + UIApplication.shared.open(URL(string: "https://jellyfin.org/docs/general/administration/troubleshooting")!) + } ForEach(viewModel.logs, id: \.self) { log in Button { let request = Paths.getLogFile(name: log.name!) diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/Components/UserFunctionButton.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/Components/UserFunctionButton.swift new file mode 100644 index 000000000..6a7600032 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/Components/UserFunctionButton.swift @@ -0,0 +1,45 @@ +// +// 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 + +extension UserAdministrationDetailView { + + struct UserFunctionButton: View { + + let title: String + let systemImage: String + let warningMessage: String + let isPresented: Binding + let isDestructive: Bool + let action: () -> Void + + // MARK: - Body + + var body: some View { + Button(role: isDestructive ? .destructive : .none) { + isPresented.wrappedValue = true + } label: { + Text(title) + } + .buttonStyle(.bordered) + .padding() + .confirmationDialog( + title, + isPresented: isPresented, + titleVisibility: .hidden + ) { + Button(title, role: .destructive, action: action) + } message: { + Text(warningMessage) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/UserAdministrationDetailView.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/UserAdministrationDetailView.swift new file mode 100644 index 000000000..2b8d6b4fa --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationDetailView/UserAdministrationDetailView.swift @@ -0,0 +1,82 @@ +// +// 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 UserAdministrationDetailView: View { + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @ObservedObject + var observer: UserAdministrationObserver + + @State + var isPasswordResetPresenting: Bool = false + @State + var isPasswordUpdatePresenting: Bool = false + @State + var tempPassword: String = "" + @State + var tempNewPassword: String = "" + @State + var tempPasswordConfirm: String = "" + + var body: some View { + VStack { + Text(observer.user.name ?? "") + .font(.title) + .padding() + + // Current Password Input + TextField("Current Password", text: $tempPassword) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + + // New Password Input + TextField("New Password", text: $tempNewPassword) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + + // Confirm Password Input + TextField("Confirm Password", text: $tempPasswordConfirm) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + + HStack { + UserFunctionButton( + title: "Reset Password", + systemImage: "lock.rotation", + warningMessage: "Are you sure you want to reset \(observer.user.name ?? L10n.unknown)'s password?", + isPresented: $isPasswordResetPresenting, + isDestructive: true + ) { + observer.send(.resetPassword) + } + Spacer() + UserFunctionButton( + title: "Save Password", + systemImage: "lock.circle.dotted", + warningMessage: "Are you sure you want to update \(observer.user.name ?? L10n.unknown)'s password?", + isPresented: $isPasswordUpdatePresenting, + isDestructive: false + ) { + if tempNewPassword == tempPasswordConfirm { + observer.send(.updatePassword(currentPassword: tempPassword, newPassword: tempNewPassword)) + } else { + print("Passwords do not match") + } + } + } + } + .navigationTitle(L10n.user) + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationButton.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationButton.swift new file mode 100644 index 000000000..79e9b87d6 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationButton.swift @@ -0,0 +1,68 @@ +// +// 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 Factory +import JellyfinAPI +import SwiftUI + +extension UserAdministrationView { + + struct UserAdministrationButton: View { + + @Injected(\.currentUserSession) + private var userSession: UserSession! + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @ObservedObject + var observer: UserAdministrationObserver + + var body: some View { + Button { + router.route(to: \.userDetails, observer) + } label: { + VStack(alignment: .leading, spacing: 8) { + UserProfileImage(observer: observer) + .aspectRatio(1, contentMode: .fill) + .clipShape(Rectangle()) + .frame(width: 150, height: 150) + + Text(observer.user.name ?? .emptyDash) + .fontWeight(.semibold) + .foregroundStyle(.primary) + + TextPairView( + L10n.lastSeen, + value: Text(formatLastSeenDate(observer.user.lastActivityDate)) + ) + .font(.footnote) + } + .padding() + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(radius: 2) + } + .background(Color(.systemGray).opacity(0.1)) + .foregroundStyle(.primary, .secondary) + } + + // MARK: - Format Last Seen Date + + private func formatLastSeenDate(_ date: Date?) -> String { + guard let date = date else { + return L10n.never + } + + let timeInterval = Date().timeIntervalSince(date) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + + return formatter.localizedString(for: date, relativeTo: Date()) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationRow.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationRow.swift new file mode 100644 index 000000000..c3674e26b --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserAdministrationRow.swift @@ -0,0 +1,68 @@ +// +// 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 Factory +import JellyfinAPI +import SwiftUI + +extension UserAdministrationView { + + struct UserAdministrationRow: View { + + @Injected(\.currentUserSession) + private var userSession: UserSession! + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @ObservedObject + var observer: UserAdministrationObserver + + // MARK: - Body + + var body: some View { + Button { + router.route(to: \.userDetails, observer) + } label: { + VStack(spacing: 8) { + HStack { + UserProfileImage(observer: observer) + .frame(width: 60, height: 60) + VStack(alignment: .leading) { + Text(observer.user.name ?? L10n.unknown) + .foregroundStyle(.foreground) + .font(.headline) + Spacer() + TextPairView( + L10n.lastSeen, + value: Text(formatLastSeenDate(observer.user.lastActivityDate)) + ) + } + } + Divider() + } + } + } + + // MARK: - Format Last Seen Date + + private func formatLastSeenDate(_ date: Date?) -> String { + guard let date = date else { + return L10n.never + } + + let timeInterval = Date().timeIntervalSince(date) + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + + return formatter.localizedString(for: date, relativeTo: Date()) + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserProfileView.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserProfileView.swift new file mode 100644 index 000000000..9e6c9dd13 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/Components/UserProfileView.swift @@ -0,0 +1,37 @@ +// +// 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 Factory +import JellyfinAPI +import SwiftUI + +extension UserAdministrationView { + + struct UserProfileImage: View { + + @Injected(\.currentUserSession) + private var userSession: UserSession! + + @ObservedObject + var observer: UserAdministrationObserver + + @ViewBuilder + var body: some View { + ImageView(observer.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) + } + } + } +} diff --git a/Swiftfin/Views/AdminDashboardView/UserAdministrationView/UserAdministrationView.swift b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/UserAdministrationView.swift new file mode 100644 index 000000000..b7dfeb1e3 --- /dev/null +++ b/Swiftfin/Views/AdminDashboardView/UserAdministrationView/UserAdministrationView.swift @@ -0,0 +1,130 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionVGrid +import Defaults +import JellyfinAPI +import SwiftUI + +struct UserAdministrationView: View { + + @EnvironmentObject + private var router: AdminDashboardCoordinator.Router + + @StateObject + private var viewModel = UserAdministrationViewModel() + + @State + private var libraryDisplayType: LibraryDisplayType = .grid + @State + private var layout: CollectionVGridLayout + + // MARK: - Init + + init() { + _layout = State(initialValue: Self.gridLayout) + } + + // MARK: - Grid and List Layout + + private static var gridLayout: CollectionVGridLayout { + if UIDevice.current.userInterfaceIdiom == .pad { + return .minWidth(150, insets: .edgeInsets, itemSpacing: 16, lineSpacing: 2) + } else { + return .columns(2, insets: .edgeInsets, itemSpacing: 8, lineSpacing: 8) + } + } + + private static var listLayout: CollectionVGridLayout { + .columns(1, insets: .edgeInsets, itemSpacing: 8, lineSpacing: 8) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial: + DelayedProgressView() + } + } + .navigationTitle(L10n.users) + .onFirstAppear { + viewModel.send(.getUsers) + } + .refreshable { + viewModel.send(.getUsers) + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.gettingUsers) { + ProgressView() + } + + Button(action: { + toggleView() + }) { + Image(systemName: libraryDisplayType == .list ? "square.grid.2x2" : "list.bullet") + } + } + } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + if viewModel.users.isEmpty { + Text(L10n.none) + } else { + CollectionVGrid( + viewModel.users.keys, + layout: $layout + ) { id in + if let user = viewModel.users[id]?.value { + if libraryDisplayType == .grid { + UserAdministrationButton( + observer: UserAdministrationObserver(user: user) + ) + .frame(maxWidth: .infinity) + } else { + UserAdministrationRow( + observer: UserAdministrationObserver(user: user) + ) + .frame(maxWidth: .infinity) + } + } + } + } + } + + // MARK: - Error View + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.getUsers) + } + } + + // MARK: - Toggle Between Grid and List Views + + private func toggleView() { + switch libraryDisplayType { + case .list: + libraryDisplayType = .grid + layout = Self.gridLayout + case .grid: + libraryDisplayType = .list + layout = Self.listLayout + } + } +} diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift index 64da0366a..4f9e2d20c 100644 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -87,11 +87,19 @@ struct HomeView: View { ProgressView() } - SettingsBarButton( - server: viewModel.userSession.server, - user: viewModel.userSession.user - ) { - mainRouter.route(to: \.settings) + HStack { + if viewModel.userSession.user.isAdministrator { + ActiveSessionIndicator { + mainRouter.route(to: \.adminDashboard) + } + } + + SettingsBarButton( + server: viewModel.userSession.server, + user: viewModel.userSession.user + ) { + mainRouter.route(to: \.settings) + } } } .sinceLastDisappear { interval in diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift index bb7da2d09..2a9db5fdf 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift @@ -20,7 +20,15 @@ extension CustomizeViewsSettings { private var resumeNextUp @State - private var isPresentingNextUpDays = false + var tempNextUp: TimeInterval? + + // MARK: - Init + + init() { + _tempNextUp = State(initialValue: maxNextUp) + } + + // MARK: - Body var body: some View { Section(L10n.home) { @@ -29,9 +37,9 @@ extension CustomizeViewsSettings { Toggle(L10n.nextUpRewatch, isOn: $resumeNextUp) - ChevronButton( - L10n.nextUpDays, - subtitle: { + ChevronInputButton( + title: L10n.nextUpDays, + subtitleText: { if maxNextUp > 0 { return Text( Date.now.addingTimeInterval(-maxNextUp) ..< Date.now, @@ -40,22 +48,21 @@ extension CustomizeViewsSettings { } else { return Text(L10n.disabled) } - }() - ) - .onSelect { - isPresentingNextUpDays = true - } - .alert(L10n.nextUpDays, isPresented: $isPresentingNextUpDays) { - + }(), + description: L10n.nextUpDaysDescription + ) { TextField( L10n.nextUpDays, - value: $maxNextUp, + value: $tempNextUp, format: .dayInterval(range: 0 ... 1000) ) .keyboardType(.numberPad) - - } message: { - L10n.nextUpDaysDescription.text + } onSave: { + if let tempNextUp = tempNextUp { + maxNextUp = tempNextUp + } + } onCancel: { + tempNextUp = maxNextUp } } } diff --git a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift index 22fa795c4..c807c0b40 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift @@ -46,7 +46,7 @@ struct SettingsView: View { if viewModel.userSession.user.isAdministrator { ChevronButton(L10n.dashboard) .onSelect { - router.route(to: \.userDashboard) + router.route(to: \.adminDashboard) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift deleted file mode 100644 index 968ecb41b..000000000 --- a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// Swiftfin is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, you can obtain one at https://mozilla.org/MPL/2.0/. -// -// Copyright (c) 2024 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -// TODO: last run details -// - result, show error if available -// TODO: observe running status -// - stop -// - run -// - progress -// TODO: triggers - -struct EditScheduledTaskView: View { - - @CurrentDate - private var currentDate: Date - - @ObservedObject - var observer: ServerTaskObserver - - var body: some View { - List { - - ListTitleSection( - observer.task.name ?? L10n.unknown, - description: observer.task.description - ) - - if let category = observer.task.category { - TextPairView( - leading: L10n.category, - trailing: category - ) - } - - if let lastEndTime = observer.task.lastExecutionResult?.endTimeUtc { - TextPairView( - L10n.lastRun, - value: Text("\(lastEndTime, format: .relative(presentation: .numeric, unitsStyle: .narrow))") - ) - .id(currentDate) - .monospacedDigit() - - if let lastStartTime = observer.task.lastExecutionResult?.startTimeUtc { - TextPairView( - L10n.runtime, - value: Text( - "\(lastStartTime ..< lastEndTime, format: .components(style: .narrow))" - ) - ) - } - } - } - .navigationTitle(L10n.task) - } -} - -// TODO: remove after view done -#Preview { - NavigationView { - EditScheduledTaskView( - observer: .init( - task: TaskInfo( - category: "test", - currentProgressPercentage: nil, - description: "A test task", - id: "123", - isHidden: false, - key: "123", - lastExecutionResult: TaskResult( - endTimeUtc: Date(timeIntervalSinceNow: -10), - errorMessage: nil, - id: nil, - key: nil, - longErrorMessage: nil, - name: nil, - startTimeUtc: Date(timeIntervalSinceNow: -30), - status: .completed - ), - name: "Test", - state: .running, - triggers: nil - ) - ) - ) - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 4d5157e5e..d2c35afee 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ