diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 526b65e6..dcb1bab4 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -49,15 +49,21 @@ final class SettingsCoordinator: NavigationCoordinatable { var videoPlayerSettings = makeVideoPlayerSettings @Route(.push) var customDeviceProfileSettings = makeCustomDeviceProfileSettings + @Route(.modal) + var itemOverviewView = makeItemOverviewView + @Route(.modal) + var editCustomDeviceProfile = makeEditCustomDeviceProfile + @Route(.modal) + var createCustomDeviceProfile = makeCreateCustomDeviceProfile + + // TODO: Move AdminDashboard items to its own coordinator -> @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) @@ -65,14 +71,12 @@ final class SettingsCoordinator: NavigationCoordinatable { @Route(.push) var deviceDetails = makeDeviceDetails @Route(.push) - var editScheduledTask = makeEditScheduledTask + var editServerTask = makeEditServerTask + @Route(.modal) + var addServerTaskTrigger = makeAddServerTaskTrigger @Route(.push) var serverLogs = makeServerLogs - - @Route(.modal) - var editCustomDeviceProfile = makeEditCustomDeviceProfile - @Route(.modal) - var createCustomDeviceProfile = makeCreateCustomDeviceProfile + // <- End of AdminDashboard Items #if DEBUG @Route(.push) @@ -164,6 +168,22 @@ final class SettingsCoordinator: NavigationCoordinatable { EditServerView(server: server) } + func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ItemOverviewView(item: item) + } + } + + func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View { + OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases) + .navigationTitle(L10n.filters) + } + + func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator { + VideoPlayerSettingsCoordinator() + } + + // TODO: Move AdminDashboard items to its own coordinator -> @ViewBuilder func makeUserDashboard() -> some View { UserDashboardView() @@ -179,15 +199,9 @@ final class SettingsCoordinator: NavigationCoordinatable { ActiveSessionDetailView(box: box) } - func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator { - ItemOverviewView(item: item) - } - } - @ViewBuilder func makeTasks() -> some View { - ScheduledTasksView() + ServerTasksView() } @ViewBuilder @@ -201,8 +215,14 @@ final class SettingsCoordinator: NavigationCoordinatable { } @ViewBuilder - func makeEditScheduledTask(observer: ServerTaskObserver) -> some View { - EditScheduledTaskView(observer: observer) + func makeEditServerTask(observer: ServerTaskObserver) -> some View { + EditServerTaskView(observer: observer) + } + + func makeAddServerTaskTrigger(observer: ServerTaskObserver) -> NavigationViewCoordinator { + NavigationViewCoordinator { + AddTaskTriggerView(observer: observer) + } } @ViewBuilder @@ -210,14 +230,7 @@ final class SettingsCoordinator: NavigationCoordinatable { ServerLogsView() } - func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View { - OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases) - .navigationTitle(L10n.filters) - } - - func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator { - VideoPlayerSettingsCoordinator() - } + // <- End of AdminDashboard Items #if DEBUG @ViewBuilder diff --git a/Shared/Extensions/JellyfinAPI/DayOfWeek.swift b/Shared/Extensions/JellyfinAPI/DayOfWeek.swift new file mode 100644 index 00000000..75bfb319 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DayOfWeek.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 Foundation +import JellyfinAPI + +extension DayOfWeek { + + var displayTitle: String? { + let newLineRemoved = rawValue.replacingOccurrences(of: "\n", with: "") + + guard let index = DateFormatter().weekdaySymbols.firstIndex(of: newLineRemoved) else { + return nil + } + + return Calendar.current + .weekdaySymbols[index] + .localizedCapitalized + } +} diff --git a/Shared/Extensions/JellyfinAPI/ServerTicks.swift b/Shared/Extensions/JellyfinAPI/ServerTicks.swift new file mode 100644 index 00000000..c23eefe2 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/ServerTicks.swift @@ -0,0 +1,87 @@ +// +// 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: remove and have sdk use strong types instead + +typealias ServerTicks = Int + +extension ServerTicks { + + // MARK: - Conversion Constants + + private static let ticksPerSecond = 10_000_000 + private static let ticksPerMinute = 600_000_000 + private static let ticksPerHour = 36_000_000_000 + private static let ticksPerDay = 864_000_000_000 + + // MARK: - Initializers + + init(_ ticks: Int? = nil) { + self = ticks ?? 0 + } + + init(seconds: Int? = nil) { + self = (seconds ?? 0) * ServerTicks.ticksPerSecond + } + + init(minutes: Int? = nil) { + self = (minutes ?? 0) * ServerTicks.ticksPerMinute + } + + init(hours: Int? = nil) { + self = (hours ?? 0) * ServerTicks.ticksPerHour + } + + init(days: Int? = nil) { + self = (days ?? 0) * ServerTicks.ticksPerDay + } + + init(timeInterval: TimeInterval? = nil) { + self = Int((timeInterval ?? 0) * Double(ServerTicks.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 = Int(totalSeconds * 10_000_000) + } + + // MARK: - Computed Properties + + var ticks: Int { + self + } + + var seconds: TimeInterval { + TimeInterval(self) / Double(ServerTicks.ticksPerSecond) + } + + var minutes: TimeInterval { + TimeInterval(self) / Double(ServerTicks.ticksPerMinute) + } + + var hours: TimeInterval { + TimeInterval(self) / Double(ServerTicks.ticksPerHour) + } + + var days: TimeInterval { + TimeInterval(self) / Double(ServerTicks.ticksPerDay) + } + + var date: Date { + let totalSeconds = TimeInterval(self) / 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/Shared/Extensions/JellyfinAPI/TaskState.swift b/Shared/Extensions/JellyfinAPI/TaskState.swift new file mode 100644 index 00000000..50ba73b9 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/TaskState.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: Displayable { + + var displayTitle: String { + switch self { + case .cancelling: + return L10n.cancelling + case .idle: + return L10n.idle + case .running: + return L10n.running + } + } +} diff --git a/Shared/Extensions/JellyfinAPI/TaskTriggerType.swift b/Shared/Extensions/JellyfinAPI/TaskTriggerType.swift new file mode 100644 index 00000000..add2d13c --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/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 73588c8d..d550efee 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") /// ActiveSessionsView Header internal static let activeDevices = L10n.tr("Localizable", "activeDevices", fallback: "Active Devices") + /// Add + internal static let add = L10n.tr("Localizable", "add", fallback: "Add") /// Select Server View - Add Server internal static let addServer = L10n.tr("Localizable", "addServer", fallback: "Add Server") + /// Add trigger + internal static let addTrigger = L10n.tr("Localizable", "addTrigger", fallback: "Add trigger") /// Add URL internal static let addURL = L10n.tr("Localizable", "addURL", fallback: "Add URL") /// Administration Dashboard Section @@ -150,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 @@ -234,14 +240,18 @@ internal enum L10n { internal static let customize = L10n.tr("Localizable", "customize", fallback: "Customize") /// Section Header for a Custom Device Profile internal static let customProfile = L10n.tr("Localizable", "customProfile", fallback: "Custom Profile") + /// Daily + internal static let daily = L10n.tr("Localizable", "daily", fallback: "Daily") /// Represents the dark theme setting internal static let dark = L10n.tr("Localizable", "dark", fallback: "Dark") /// UserDashboardView Header internal static let dashboard = L10n.tr("Localizable", "dashboard", fallback: "Dashboard") /// Description for the dashboard section 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") /// Time Interval Help Text - Days - internal static let days = L10n.tr("Localizable", "days", fallback: "days") + internal static let days = L10n.tr("Localizable", "days", fallback: "Days") /// Default Scheme internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") /// Server Detail View - Delete @@ -262,8 +272,14 @@ internal enum L10n { internal static let deleteSelectionDevicesWarning = L10n.tr("Localizable", "deleteSelectionDevicesWarning", fallback: "Are you sure you wish to delete all selected devices? All selected sessions will be logged out.") /// Server Detail View - Delete Server internal static let deleteServer = L10n.tr("Localizable", "deleteServer", fallback: "Delete Server") + /// Delete Trigger + internal static let deleteTrigger = L10n.tr("Localizable", "deleteTrigger", fallback: "Delete Trigger") + /// Are you sure you want to delete this trigger? This action cannot be undone. + internal static let deleteTriggerConfirmationMessage = L10n.tr("Localizable", "deleteTriggerConfirmationMessage", fallback: "Are you sure you want to delete this trigger? This action cannot be undone.") /// Delivery internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery") + /// Details + internal static let details = L10n.tr("Localizable", "details", fallback: "Details") /// Session Device Section Label internal static let device = L10n.tr("Localizable", "device", fallback: "Device") /// Section Header for Device Profiles @@ -282,6 +298,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 @@ -312,6 +330,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 @@ -344,16 +372,26 @@ 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") + /// Idle + internal static let idle = L10n.tr("Localizable", "idle", fallback: "Idle") /// Customize Server View - Indicators internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators") /// Information internal static let information = L10n.tr("Localizable", "information", fallback: "Information") /// TranscodeReason - Interlaced Video 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") /// 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$@") + } /// SessionPlaybackMethod Remaining Time internal static func itemOverItem(_ p1: Any, _ p2: Any) -> String { return L10n.tr("Localizable", "itemOverItem", String(describing: p1), String(describing: p2), fallback: "%1$@ / %2$@") @@ -420,6 +458,8 @@ internal enum L10n { } /// Settings View - 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.") /// Option to set the maximum bitrate for playback internal static let maximumBitrate = L10n.tr("Localizable", "maximumBitrate", fallback: "Maximum Bitrate") /// Playback May Fail @@ -430,6 +470,8 @@ internal enum L10n { internal static let menuButtons = L10n.tr("Localizable", "menuButtons", fallback: "Menu Buttons") /// The play method (e.g., Direct Play, Transcoding) internal static let method = L10n.tr("Localizable", "method", fallback: "Method") + /// Minutes + internal static let minutes = L10n.tr("Localizable", "minutes", fallback: "Minutes") /// Missing internal static let missing = L10n.tr("Localizable", "missing", fallback: "Missing") /// Missing Items @@ -488,6 +530,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 active session available internal static let noSession = L10n.tr("Localizable", "noSession", fallback: "No session") /// N/A @@ -502,6 +546,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") /// Indicates that something is Online @@ -674,7 +720,7 @@ 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 - Completed, end, or save + /// Save internal static let save = L10n.tr("Localizable", "save", fallback: "Save") /// Administration Dashboard Scan All Libraries Button internal static let scanAllLibraries = L10n.tr("Localizable", "scanAllLibraries", fallback: "Scan All Libraries") @@ -730,6 +776,14 @@ internal enum L10n { internal static let serverLogs = L10n.tr("Localizable", "serverLogs", fallback: "Server Logs") /// Select Server View internal static let servers = L10n.tr("Localizable", "servers", fallback: "Servers") + /// A new trigger was created for '%1$@'. + internal static func serverTriggerCreated(_ p1: Any) -> String { + return L10n.tr("Localizable", "serverTriggerCreated", String(describing: p1), fallback: "A new trigger was created for '%1$@'.") + } + /// The selected trigger was deleted from '%1$@'. + internal static func serverTriggerDeleted(_ p1: Any) -> String { + return L10n.tr("Localizable", "serverTriggerDeleted", String(describing: p1), fallback: "The selected trigger was deleted from '%1$@'.") + } /// Server URL internal static let serverURL = L10n.tr("Localizable", "serverURL", fallback: "Server URL") /// The title for the session view @@ -796,6 +850,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") /// Button label to stop a task internal static let stop = L10n.tr("Localizable", "stop", fallback: "Stop") /// Session Streaming Clients @@ -854,8 +910,24 @@ internal enum L10n { internal static let tasks = L10n.tr("Localizable", "tasks", fallback: "Tasks") /// Description for the tasks section internal static let tasksDescription = L10n.tr("Localizable", "tasksDescription", fallback: "Tasks are operations that are scheduled to run periodically or can be triggered manually.") + /// Sets the duration (in minutes) in between task triggers. + internal static let taskTriggerInterval = L10n.tr("Localizable", "taskTriggerInterval", fallback: "Sets the duration (in minutes) in between task triggers.") + /// 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.") /// Option to set the test size for bitrate testing 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 timeLimitLabelWithValue(_ p1: Any) -> String { + return L10n.tr("Localizable", "timeLimitLabelWithValue", 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 @@ -870,10 +942,16 @@ 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") + /// Trigger already exists + internal static let triggerAlreadyExists = L10n.tr("Localizable", "triggerAlreadyExists", fallback: "Trigger already exists") + /// Triggers + internal static let triggers = L10n.tr("Localizable", "triggers", fallback: "Triggers") /// Try again internal static let tryAgain = L10n.tr("Localizable", "tryAgain", fallback: "Try again") /// TV Shows internal static let tvShows = L10n.tr("Localizable", "tvShows", fallback: "TV Shows") + /// Indicate a type + internal static let type = L10n.tr("Localizable", "type", fallback: "Type") /// Unable to connect to server internal static let unableToConnectServer = L10n.tr("Localizable", "unableToConnectServer", fallback: "Unable to connect to server") /// Unable to find host @@ -894,6 +972,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") /// Override Transcoding Profile @@ -934,6 +1014,8 @@ internal enum L10n { internal static let videoRangeTypeNotSupported = L10n.tr("Localizable", "videoRangeTypeNotSupported", fallback: "The video range type is not supported") /// TranscodeReason - Video Resolution 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/DevicesViewModel.swift b/Shared/ViewModels/DevicesViewModel.swift index 01de5d1e..7cde03ce 100644 --- a/Shared/ViewModels/DevicesViewModel.swift +++ b/Shared/ViewModels/DevicesViewModel.swift @@ -100,7 +100,7 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { } await MainActor.run { - self?.backgroundStates.remove(.gettingDevices) + let _ = self?.backgroundStates.remove(.gettingDevices) } } .asAnyCancellable() @@ -129,7 +129,7 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { } await MainActor.run { - self?.backgroundStates.remove(.settingCustomName) + let _ = self?.backgroundStates.remove(.settingCustomName) } } .asAnyCancellable() @@ -157,7 +157,7 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { } await MainActor.run { - self?.backgroundStates.remove(.deletingDevices) + let _ = self?.backgroundStates.remove(.deletingDevices) } } .asAnyCancellable() @@ -203,7 +203,7 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { let request = Paths.updateDeviceOptions(id: id, DeviceOptionsDto(customName: newName)) try await userSession.client.send(request) - if let device = self.devices[id]?.value { + if let _ = devices[id]?.value { await MainActor.run { self.devices[id]?.value?.name = newName } @@ -222,7 +222,7 @@ final class DevicesViewModel: ViewModel, Eventful, Stateful { try await userSession.client.send(request) await MainActor.run { - self.devices.removeValue(forKey: id) + let _ = self.devices.removeValue(forKey: id) } } diff --git a/Shared/ViewModels/ScheduledTasksViewModel.swift b/Shared/ViewModels/ScheduledTasksViewModel.swift index cdb2c049..e371a3fa 100644 --- a/Shared/ViewModels/ScheduledTasksViewModel.swift +++ b/Shared/ViewModels/ScheduledTasksViewModel.swift @@ -15,7 +15,7 @@ import SwiftUI // TODO: do something for errors from restart/shutdown // - toast? -final class ScheduledTasksViewModel: ViewModel, Stateful { +final class ServerTasksViewModel: ViewModel, Stateful { // MARK: - Action diff --git a/Shared/ViewModels/ServerTaskObserver.swift b/Shared/ViewModels/ServerTaskObserver.swift index efc0ad59..4a0e1a0e 100644 --- a/Shared/ViewModels/ServerTaskObserver.swift +++ b/Shared/ViewModels/ServerTaskObserver.swift @@ -9,38 +9,76 @@ import Combine import Foundation import JellyfinAPI +import OrderedCollections // TODO: refactor with socket implementation -// TODO: edit triggers +// TODO: for trigger updating, could temp set new triggers +// and set back on failure -final class ServerTaskObserver: ViewModel, Stateful, Identifiable { +final class ServerTaskObserver: ViewModel, Stateful, Eventful, Identifiable { + + // MARK: Event + + enum Event { + case error(JellyfinAPIError) + } + + enum BackgroundState { + case updatingTriggers + } + + // MARK: Action enum Action: Equatable { case start case stop case stopObserving + case addTrigger(TaskTriggerInfo) + case removeTrigger(TaskTriggerInfo) } + // MARK: State + enum State: Hashable { case error(JellyfinAPIError) case initial case running } + // MARK: Published Values + + @Published + final var backgroundStates: OrderedSet = [] @Published final var state: State = .initial @Published private(set) var task: TaskInfo + // MARK: Cancellable Tasks + private var progressCancellable: AnyCancellable? private var cancelCancellable: AnyCancellable? + // MARK: Initialize from TaskId + var id: String? { task.id } init(task: TaskInfo) { self.task = task } + // MARK: Event Variables + + private var eventSubject: PassthroughSubject = .init() + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + // MARK: Respond to Action + func respond(to action: Action) -> State { switch action { case .start: @@ -58,6 +96,7 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { } catch { await MainActor.run { self.state = .error(.init(error.localizedDescription)) + self.eventSubject.send(.error(.init(error.localizedDescription))) } } } @@ -78,6 +117,7 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { } catch { await MainActor.run { self.state = .error(.init(error.localizedDescription)) + self.eventSubject.send(.error(.init(error.localizedDescription))) } } } @@ -89,9 +129,65 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { cancelCancellable?.cancel() return .initial + case let .addTrigger(trigger): + progressCancellable?.cancel() + cancelCancellable?.cancel() + + cancelCancellable = Task { + let updatedTriggers = (task.triggers ?? []) + .appending(trigger) + + await MainActor.run { + _ = self.backgroundStates.append(.updatingTriggers) + } + + do { + try await updateTriggers(updatedTriggers) + } catch { + await MainActor.run { + self.eventSubject.send(.error(.init(error.localizedDescription))) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.updatingTriggers) + } + } + .asAnyCancellable() + + return .running + case let .removeTrigger(trigger): + progressCancellable?.cancel() + cancelCancellable?.cancel() + + cancelCancellable = Task { + var updatedTriggers = (task.triggers ?? []) + updatedTriggers.removeAll { $0 == trigger } + + await MainActor.run { + _ = self.backgroundStates.append(.updatingTriggers) + } + + do { + try await updateTriggers(updatedTriggers) + } catch { + await MainActor.run { + self.eventSubject.send(.error(.init(error.localizedDescription))) + } + } + + await MainActor.run { + _ = self.backgroundStates.remove(.updatingTriggers) + } + } + .asAnyCancellable() + + return .running } } + // MARK: Start Task + private func start() async throws { guard let id = task.id else { return } @@ -101,6 +197,8 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { try await pollTaskProgress(id: id) } + // MARK: Poll Task Progress + private func pollTaskProgress(id: String) async throws { while true { let request = Paths.getTask(taskID: id) @@ -118,10 +216,26 @@ final class ServerTaskObserver: ViewModel, Stateful, Identifiable { } } + // MARK: Stop Task + private func stop() async throws { guard let id = task.id else { return } let request = Paths.stopTask(taskID: id) try await userSession.client.send(request) + + try await pollTaskProgress(id: id) + } + + // MARK: Update Triggers + + 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/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 10ca296a..99cacb0c 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -24,8 +24,8 @@ 4E16FD582C01A32700110147 /* LetterPickerOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */; }; 4E17498E2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */; }; 4E17498F2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */; }; - 4E182C9C2C94993200FBEFD5 /* ScheduledTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9B2C94993200FBEFD5 /* ScheduledTasksView.swift */; }; - 4E182C9F2C94A1E000FBEFD5 /* ScheduledTaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */; }; + 4E182C9C2C94993200FBEFD5 /* ServerTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9B2C94993200FBEFD5 /* ServerTasksView.swift */; }; + 4E182C9F2C94A1E000FBEFD5 /* ServerTaskRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */; }; 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 */; }; @@ -43,6 +43,19 @@ 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 */; }; + 4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE562CBED3F300DBD886 /* TimeRow.swift */; }; + 4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE572CBED3F300DBD886 /* TriggerTypeRow.swift */; }; + 4E35CE5E2CBED3F300DBD886 /* AddTaskTriggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE5A2CBED3F300DBD886 /* AddTaskTriggerView.swift */; }; + 4E35CE5F2CBED3F300DBD886 /* IntervalRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE542CBED3F300DBD886 /* IntervalRow.swift */; }; + 4E35CE602CBED3F300DBD886 /* DayOfWeekRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE532CBED3F300DBD886 /* DayOfWeekRow.swift */; }; + 4E35CE612CBED3F300DBD886 /* TimeLimitSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE552CBED3F300DBD886 /* TimeLimitSection.swift */; }; + 4E35CE642CBED69600DBD886 /* TaskTriggerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE632CBED69600DBD886 /* TaskTriggerType.swift */; }; + 4E35CE662CBED8B600DBD886 /* ServerTicks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */; }; + 4E35CE672CBED8B600DBD886 /* ServerTicks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */; }; + 4E35CE692CBED95F00DBD886 /* DayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE682CBED95F00DBD886 /* DayOfWeek.swift */; }; + 4E35CE6A2CBED95F00DBD886 /* DayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE682CBED95F00DBD886 /* DayOfWeek.swift */; }; + 4E35CE6C2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; }; + 4E35CE6D2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; }; 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */; }; @@ -57,6 +70,12 @@ 4E762AAF2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; + 4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75B2CC72B1F00417C31 /* LastRunSection.swift */; }; + 4E90F7652CC72B1F00417C31 /* EditServerTaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F7612CC72B1F00417C31 /* EditServerTaskView.swift */; }; + 4E90F7662CC72B1F00417C31 /* LastErrorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75A2CC72B1F00417C31 /* LastErrorSection.swift */; }; + 4E90F7672CC72B1F00417C31 /* TriggerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */; }; + 4E90F7682CC72B1F00417C31 /* TriggersSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75D2CC72B1F00417C31 /* TriggersSection.swift */; }; + 4E90F76A2CC72B1F00417C31 /* DetailsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F7592CC72B1F00417C31 /* DetailsSection.swift */; }; 4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */; }; 4E9A24E82C82B6190023DA83 /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */; }; 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; @@ -64,7 +83,7 @@ 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.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 */; }; + 4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */; }; 4EB1A8CE2C9B2D0800F43898 /* ActiveSessionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */; }; 4EB4ECE32CBEFC4D002FF2FC /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */; }; 4EB4ECE42CBEFC4D002FF2FC /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */; }; @@ -87,7 +106,7 @@ 4ECDAA9F2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; 4EDBDCD12CBDD6590033D347 /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */; }; 4EDBDCD22CBDD6590033D347 /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */; }; - 4EE141692C8BABDF0045B661 /* ProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ProgressSection.swift */; }; + 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; }; 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87462CBF824B002354D2 /* DeviceRow.swift */; }; 4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; }; 4EED87502CBF84AD002354D2 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */; }; @@ -359,6 +378,7 @@ E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */; }; E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1194F4D2BEABA9100888DB6 /* NavigationBarCloseButton.swift */; }; E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1194F4F2BEB1E3000888DB6 /* StoredValues+Temp.swift */; }; + E119696A2CC99EA9001A58BE /* ServerTaskProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11969692CC99EA9001A58BE /* ServerTaskProgressSection.swift */; }; E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; }; E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF762B8513B40045C54A /* ItemGenre.swift */; }; @@ -979,7 +999,6 @@ E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */; }; E1EBCB42278BD174009FE6E9 /* TruncatedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */; }; E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */; }; - E1ED7FD62CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */; }; E1ED7FD82CA8AF7400ACB6E3 /* ServerTaskObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */; }; E1ED7FD92CA8AF7400ACB6E3 /* ServerTaskObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */; }; E1ED7FDB2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */; }; @@ -1048,8 +1067,8 @@ 4E16FD522C01840C00110147 /* LetterPickerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerBar.swift; sourceTree = ""; }; 4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerOrientation.swift; sourceTree = ""; }; 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; - 4E182C9B2C94993200FBEFD5 /* ScheduledTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTasksView.swift; sourceTree = ""; }; - 4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTaskButton.swift; sourceTree = ""; }; + 4E182C9B2C94993200FBEFD5 /* ServerTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTasksView.swift; sourceTree = ""; }; + 4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskRow.swift; sourceTree = ""; }; 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = ""; }; 4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = ""; }; 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileAction.swift; sourceTree = ""; }; @@ -1061,6 +1080,16 @@ 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 = ""; }; + 4E35CE532CBED3F300DBD886 /* DayOfWeekRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekRow.swift; sourceTree = ""; }; + 4E35CE542CBED3F300DBD886 /* IntervalRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalRow.swift; sourceTree = ""; }; + 4E35CE552CBED3F300DBD886 /* TimeLimitSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLimitSection.swift; sourceTree = ""; }; + 4E35CE562CBED3F300DBD886 /* TimeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeRow.swift; sourceTree = ""; }; + 4E35CE572CBED3F300DBD886 /* TriggerTypeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerTypeRow.swift; sourceTree = ""; }; + 4E35CE5A2CBED3F300DBD886 /* AddTaskTriggerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTaskTriggerView.swift; sourceTree = ""; }; + 4E35CE632CBED69600DBD886 /* TaskTriggerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTriggerType.swift; sourceTree = ""; }; + 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTicks.swift; sourceTree = ""; }; + 4E35CE682CBED95F00DBD886 /* DayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeek.swift; sourceTree = ""; }; + 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskState.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 = ""; }; 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = ""; }; @@ -1071,13 +1100,19 @@ 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 = ""; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; + 4E90F7592CC72B1F00417C31 /* DetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsSection.swift; sourceTree = ""; }; + 4E90F75A2CC72B1F00417C31 /* LastErrorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastErrorSection.swift; sourceTree = ""; }; + 4E90F75B2CC72B1F00417C31 /* LastRunSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastRunSection.swift; sourceTree = ""; }; + 4E90F75D2CC72B1F00417C31 /* TriggersSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggersSection.swift; sourceTree = ""; }; + 4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRow.swift; sourceTree = ""; }; + 4E90F7612CC72B1F00417C31 /* EditServerTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerTaskView.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 = ""; }; 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 = ""; }; + 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = ""; }; 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionRow.swift; sourceTree = ""; }; 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = ""; }; 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronAlertButton.swift; sourceTree = ""; }; @@ -1092,7 +1127,7 @@ 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = ""; }; 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = ""; }; 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = ""; }; - 4EE141682C8BABDF0045B661 /* ProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressSection.swift; sourceTree = ""; }; + 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = ""; }; 4EED87462CBF824B002354D2 /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = ""; }; 4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = ""; }; 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = ""; }; @@ -1308,6 +1343,7 @@ E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundParallaxHeaderModifier.swift; sourceTree = ""; }; E1194F4D2BEABA9100888DB6 /* NavigationBarCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarCloseButton.swift; sourceTree = ""; }; E1194F4F2BEB1E3000888DB6 /* StoredValues+Temp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredValues+Temp.swift"; sourceTree = ""; }; + E11969692CC99EA9001A58BE /* ServerTaskProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskProgressSection.swift; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; E11BDF762B8513B40045C54A /* ItemGenre.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemGenre.swift; sourceTree = ""; }; E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedCaseIterable.swift; sourceTree = ""; }; @@ -1697,7 +1733,6 @@ E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerManager.swift; sourceTree = ""; }; E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TruncatedText.swift; sourceTree = ""; }; E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewView.swift; sourceTree = ""; }; - E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditScheduledTaskView.swift; sourceTree = ""; }; E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskObserver.swift; sourceTree = ""; }; E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStateInfo.swift; sourceTree = ""; }; E1ED7FDD2CAA641F00ACB6E3 /* ListTitleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTitleSection.swift; sourceTree = ""; }; @@ -1881,20 +1916,20 @@ path = Components; sourceTree = ""; }; - 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */ = { + 4E182C9A2C94991800FBEFD5 /* ServerTasksView */ = { isa = PBXGroup; children = ( 4E182C9D2C94A01600FBEFD5 /* Components */, - 4E182C9B2C94993200FBEFD5 /* ScheduledTasksView.swift */, + 4E182C9B2C94993200FBEFD5 /* ServerTasksView.swift */, ); - path = ScheduledTasksView; + path = ServerTasksView; sourceTree = ""; }; 4E182C9D2C94A01600FBEFD5 /* Components */ = { isa = PBXGroup; children = ( - 4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */, - 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */, + 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */, + 4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */, ); path = Components; sourceTree = ""; @@ -1910,6 +1945,35 @@ path = MediaComponents; sourceTree = ""; }; + 4E35CE592CBED3F300DBD886 /* Components */ = { + isa = PBXGroup; + children = ( + 4E35CE532CBED3F300DBD886 /* DayOfWeekRow.swift */, + 4E35CE542CBED3F300DBD886 /* IntervalRow.swift */, + 4E35CE552CBED3F300DBD886 /* TimeLimitSection.swift */, + 4E35CE562CBED3F300DBD886 /* TimeRow.swift */, + 4E35CE572CBED3F300DBD886 /* TriggerTypeRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4E35CE5B2CBED3F300DBD886 /* AddTaskTriggerView */ = { + isa = PBXGroup; + children = ( + 4E35CE592CBED3F300DBD886 /* Components */, + 4E35CE5A2CBED3F300DBD886 /* AddTaskTriggerView.swift */, + ); + path = AddTaskTriggerView; + sourceTree = ""; + }; + 4E35CE622CBED3FF00DBD886 /* ServerLogsView */ = { + isa = PBXGroup; + children = ( + E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, + ); + path = ServerLogsView; + sourceTree = ""; + }; 4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */ = { isa = PBXGroup; children = ( @@ -1922,14 +1986,15 @@ 4E63B9F52C8A5BEF00C25378 /* UserDashboardView */ = { isa = PBXGroup; children = ( - E1DE64902CC6F06C00E423B6 /* Components */, 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, + 4E35CE5B2CBED3F300DBD886 /* AddTaskTriggerView */, + E1DE64902CC6F06C00E423B6 /* Components */, 4E10C80F2CC030B20012CC9F /* DeviceDetailsView */, 4EED87492CBF824B002354D2 /* DevicesView */, - E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */, - 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */, - E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, + 4E90F7622CC72B1F00417C31 /* EditServerTaskView */, + 4E182C9A2C94991800FBEFD5 /* ServerTasksView */, + 4E35CE622CBED3FF00DBD886 /* ServerLogsView */, 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */, ); path = UserDashboardView; @@ -1995,6 +2060,36 @@ path = ActiveSessionDetailView; sourceTree = ""; }; + 4E90F75E2CC72B1F00417C31 /* Sections */ = { + isa = PBXGroup; + children = ( + 4E90F7592CC72B1F00417C31 /* DetailsSection.swift */, + 4E90F75A2CC72B1F00417C31 /* LastErrorSection.swift */, + 4E90F75B2CC72B1F00417C31 /* LastRunSection.swift */, + E11969692CC99EA9001A58BE /* ServerTaskProgressSection.swift */, + 4E90F75D2CC72B1F00417C31 /* TriggersSection.swift */, + ); + path = Sections; + sourceTree = ""; + }; + 4E90F7602CC72B1F00417C31 /* Components */ = { + isa = PBXGroup; + children = ( + 4E90F75E2CC72B1F00417C31 /* Sections */, + 4E90F75F2CC72B1F00417C31 /* TriggerRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4E90F7622CC72B1F00417C31 /* EditServerTaskView */ = { + isa = PBXGroup; + children = ( + 4E90F7602CC72B1F00417C31 /* Components */, + 4E90F7612CC72B1F00417C31 /* EditServerTaskView.swift */, + ); + path = EditServerTaskView; + sourceTree = ""; + }; 4E9A24E32C82B4700023DA83 /* CustomDeviceProfileSettingsView */ = { isa = PBXGroup; children = ( @@ -2025,8 +2120,8 @@ 4EB1A8D02C9B2FB600F43898 /* Components */ = { isa = PBXGroup; children = ( + 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */, 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */, - 4EE141682C8BABDF0045B661 /* ProgressSection.swift */, ); path = Components; sourceTree = ""; @@ -3640,13 +3735,12 @@ E1D37F5B2B9CF02600343D2B /* BaseItemDto */, E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */, E1002B632793CEE700E47059 /* ChapterInfo.swift */, - E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */, + 4E35CE682CBED95F00DBD886 /* DayOfWeek.swift */, 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */, 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */, 4E12F9152CBE9615006C217E /* DeviceType.swift */, 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */, - 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */, E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */, E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */, E1D842902933F87500D1041A /* ItemFields.swift */, @@ -3656,11 +3750,16 @@ E1F5F9B12BA0200500BA5014 /* MediaSourceInfo */, E122A9122788EAAD0060FA63 /* MediaStream.swift */, E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */, + E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, + 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */, 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */, E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */, E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */, E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */, + 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */, + 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */, + 4E35CE632CBED69600DBD886 /* TaskTriggerType.swift */, 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */, E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */, E18CE0B128A229E70092E7F1 /* UserDto.swift */, @@ -4428,6 +4527,7 @@ E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */, + 4E35CE6D2CBEDB7600DBD886 /* TaskState.swift in Sources */, 4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */, E102314E2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */, E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */, @@ -4628,6 +4728,7 @@ E18A17F2298C68BB00C22F62 /* MainOverlay.swift in Sources */, E1763A6A2BF3D177004DF6AB /* PublicUserRow.swift in Sources */, E1E6C44B29AED2B70064123F /* HorizontalAlignment.swift in Sources */, + 4E35CE672CBED8B600DBD886 /* ServerTicks.swift in Sources */, E193D549271941CC00900D82 /* UserSignInView.swift in Sources */, 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */, E148128628C15475003B8787 /* SortOrder+ItemSortOrder.swift in Sources */, @@ -4686,6 +4787,7 @@ E1153D962BBA3E2F00424D36 /* EpisodeHStack.swift in Sources */, E193D5512719432400900D82 /* ServerConnectionViewModel.swift in Sources */, E1B5861329E32EEF00E45D6E /* Sequence.swift in Sources */, + 4E35CE6A2CBED95F00DBD886 /* DayOfWeek.swift in Sources */, C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */, @@ -4924,6 +5026,7 @@ E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, + E119696A2CC99EA9001A58BE /* ServerTaskProgressSection.swift in Sources */, E1BAFE102BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift in Sources */, E1ED7FDE2CAA641F00ACB6E3 /* ListTitleSection.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, @@ -4932,6 +5035,7 @@ 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */, E1DD55372B6EE533007501C0 /* Task.swift in Sources */, E1ED7FE02CAA685900ACB6E3 /* ServerLogsView.swift in Sources */, + 4E35CE6C2CBEDB7600DBD886 /* TaskState.swift in Sources */, E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */, E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, @@ -4939,6 +5043,12 @@ E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, + 4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */, + 4E90F7652CC72B1F00417C31 /* EditServerTaskView.swift in Sources */, + 4E90F7662CC72B1F00417C31 /* LastErrorSection.swift in Sources */, + 4E90F7672CC72B1F00417C31 /* TriggerRow.swift in Sources */, + 4E90F7682CC72B1F00417C31 /* TriggersSection.swift in Sources */, + 4E90F76A2CC72B1F00417C31 /* DetailsSection.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, @@ -4998,7 +5108,7 @@ E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */, E12CC1AE28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */, E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, - 4EE141692C8BABDF0045B661 /* ProgressSection.swift in Sources */, + 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */, E1A1528528FD191A00600579 /* TextPair.swift in Sources */, 6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */, 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */, @@ -5044,9 +5154,15 @@ E18A8E8028D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */, E18E01DC288747230022598C /* iPadOSCinematicScrollView.swift in Sources */, E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */, + 4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */, + 4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */, + 4E35CE5E2CBED3F300DBD886 /* AddTaskTriggerView.swift in Sources */, + 4E35CE5F2CBED3F300DBD886 /* IntervalRow.swift in Sources */, + 4E35CE602CBED3F300DBD886 /* DayOfWeekRow.swift in Sources */, + 4E35CE612CBED3F300DBD886 /* TimeLimitSection.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E14EA1602BF6FF8900DE757A /* UserProfileImagePicker.swift in Sources */, - 4E182C9C2C94993200FBEFD5 /* ScheduledTasksView.swift in Sources */, + 4E182C9C2C94993200FBEFD5 /* ServerTasksView.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */, E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, @@ -5057,6 +5173,7 @@ E10B1ECA2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */, E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */, 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */, + 4E35CE692CBED95F00DBD886 /* DayOfWeek.swift in Sources */, E18E01E3288747230022598C /* CompactPortraitScrollView.swift in Sources */, 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, @@ -5076,6 +5193,7 @@ E113133A28BEB71D00930F75 /* FilterViewModel.swift in Sources */, E1E6C44C29AED2BE0064123F /* HorizontalAlignment.swift in Sources */, E1A1528D28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, + 4E35CE642CBED69600DBD886 /* TaskTriggerType.swift in Sources */, E18E01EE288747230022598C /* AboutView.swift in Sources */, 62E632E0267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */, @@ -5135,7 +5253,7 @@ E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */, E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */, E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */, - 4EB1A8CC2C9B1BA200F43898 /* ServerTaskButton.swift in Sources */, + 4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, E10B1EB62BD98C6600A92EAF /* AddUserRow.swift in Sources */, E1CB75802C80F28F00217C76 /* SubtitleProfile.swift in Sources */, @@ -5154,7 +5272,6 @@ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, E1721FAE28FB801C00762992 /* SmallPlaybackButtons.swift in Sources */, E1A7B1662B9ADAD300152546 /* ItemTypeLibraryViewModel.swift in Sources */, - E1ED7FD62CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift in Sources */, 4EB7B33B2CBDE645004A342E /* ChevronAlertButton.swift in Sources */, E1545BD82BDC55C300D9578F /* ResetUserPasswordView.swift in Sources */, E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */, @@ -5175,7 +5292,7 @@ E18E01F1288747230022598C /* PlayButton.swift in Sources */, E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */, E1F5CF092CB0A04500607465 /* Text.swift in Sources */, - 4E182C9F2C94A1E000FBEFD5 /* ScheduledTaskButton.swift in Sources */, + 4E182C9F2C94A1E000FBEFD5 /* ServerTaskRow.swift in Sources */, E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */, E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */, @@ -5221,6 +5338,7 @@ E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */, E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */, 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */, + 4E35CE662CBED8B600DBD886 /* ServerTicks.swift in Sources */, E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */, 4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */, E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */, diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ProgressSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionProgressSection.swift similarity index 100% rename from Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ProgressSection.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionProgressSection.swift diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift new file mode 100644 index 00000000..de366c1d --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/AddTaskTriggerView.swift @@ -0,0 +1,134 @@ +// +// 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 { + + @Environment(\.dismiss) + private var dismiss + + @ObservedObject + var observer: ServerTaskObserver + + @State + private var isPresentingNotSaved = false + @State + private var taskTriggerInfo: TaskTriggerInfo + + static let defaultTimeOfDayTicks = 0 + static let defaultDayOfWeek: DayOfWeek = .sunday + static let defaultIntervalTicks = 36_000_000_000 + private let emptyTaskTriggerInfo: TaskTriggerInfo + + private var hasUnsavedChanges: Bool { + taskTriggerInfo != emptyTaskTriggerInfo + } + + private var isDuplicate: Bool { + observer.task.triggers?.contains(where: { $0 == taskTriggerInfo }) ?? false + } + + // 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: - View for TaskTriggerType.daily + + @ViewBuilder + private var dailyView: some View { + TimeRow(taskTriggerInfo: $taskTriggerInfo) + } + + // MARK: - View for TaskTriggerType.weekly + + @ViewBuilder + private var weeklyView: some View { + DayOfWeekRow(taskTriggerInfo: $taskTriggerInfo) + TimeRow(taskTriggerInfo: $taskTriggerInfo) + } + + // MARK: - View for TaskTriggerType.interval + + @ViewBuilder + private var intervalView: some View { + IntervalRow(taskTriggerInfo: $taskTriggerInfo) + } + + // MARK: - Body + + var body: some View { + Form { + Section { + TriggerTypeRow(taskTriggerInfo: $taskTriggerInfo) + + if let taskType = taskTriggerInfo.type { + if taskType == TaskTriggerType.daily.rawValue { + dailyView + } else if taskType == TaskTriggerType.weekly.rawValue { + weeklyView + } else if taskType == TaskTriggerType.interval.rawValue { + intervalView + } + } + } footer: { + if isDuplicate { + Label(L10n.triggerAlreadyExists, systemImage: "exclamationmark.circle.fill") + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + } + } + + TimeLimitSection(taskTriggerInfo: $taskTriggerInfo) + } + .animation(.linear(duration: 0.2), value: isDuplicate) + .animation(.linear(duration: 0.2), value: taskTriggerInfo.type) + .interactiveDismissDisabled(true) + .navigationTitle(L10n.addTrigger) + .navigationBarTitleDisplayMode(.inline) + .navigationBarCloseButton { + if hasUnsavedChanges { + isPresentingNotSaved = true + } else { + dismiss() + } + } + .topBarTrailing { + Button(L10n.save) { + + UIDevice.impact(.light) + + observer.send(.addTrigger(taskTriggerInfo)) + dismiss() + } + .buttonStyle(.toolbarPill) + .disabled(isDuplicate) + } + .alert(L10n.unsavedChangesMessage, isPresented: $isPresentingNotSaved) { + Button(L10n.close, role: .destructive) { + dismiss() + } + Button(L10n.cancel, role: .cancel) { + isPresentingNotSaved = false + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/DayOfWeekRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/DayOfWeekRow.swift new file mode 100644 index 00000000..0a78f300 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/DayOfWeekRow.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct DayOfWeekRow: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + // MARK: - Body + + var body: some View { + 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/SettingsView/UserDashboardView/AddTaskTriggerView/Components/IntervalRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/IntervalRow.swift new file mode 100644 index 00000000..bb59c35f --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/IntervalRow.swift @@ -0,0 +1,60 @@ +// +// 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 IntervalRow: View { + + @Binding + private var taskTriggerInfo: TaskTriggerInfo + + @State + private var tempInterval: Int? + + // MARK: - Init + + init(taskTriggerInfo: Binding) { + self._taskTriggerInfo = taskTriggerInfo + _tempInterval = State(initialValue: Int(ServerTicks(taskTriggerInfo.wrappedValue.intervalTicks).minutes)) + } + + // MARK: - Body + + var body: some View { + ChevronAlertButton( + L10n.every, + subtitle: ServerTicks( + taskTriggerInfo.intervalTicks + ).seconds.formatted(.hourMinute), + description: L10n.taskTriggerInterval + ) { + TextField( + L10n.minutes, + value: $tempInterval, + format: .number + ) + .keyboardType(.numberPad) + } onSave: { + if tempInterval != nil && tempInterval != 0 { + taskTriggerInfo.intervalTicks = ServerTicks(minutes: tempInterval).ticks + } else { + taskTriggerInfo.intervalTicks = nil + } + } onCancel: { + if let intervalTicks = taskTriggerInfo.intervalTicks { + tempInterval = Int(ServerTicks(intervalTicks).minutes) + } else { + tempInterval = nil + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/TimeLimitSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/TimeLimitSection.swift new file mode 100644 index 00000000..5d73bf48 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/TimeLimitSection.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 TimeLimitSection: View { + + @Binding + private var taskTriggerInfo: TaskTriggerInfo + + @State + private var tempTimeLimit: Int? + + // MARK: - Init + + init(taskTriggerInfo: Binding) { + self._taskTriggerInfo = taskTriggerInfo + _tempTimeLimit = State(initialValue: Int(ServerTicks(taskTriggerInfo.wrappedValue.maxRuntimeTicks).hours)) + } + + // MARK: - Body + + var body: some View { + Section { + ChevronAlertButton( + L10n.timeLimit, + subtitle: subtitleString, + description: L10n.taskTriggerTimeLimit + ) { + TextField( + L10n.hours, + value: $tempTimeLimit, + format: .number + ) + .keyboardType(.numberPad) + } onSave: { + if tempTimeLimit != nil && tempTimeLimit != 0 { + taskTriggerInfo.maxRuntimeTicks = ServerTicks(hours: tempTimeLimit).ticks + } else { + taskTriggerInfo.maxRuntimeTicks = nil + } + } onCancel: { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + tempTimeLimit = Int(ServerTicks(maxRuntimeTicks).hours) + } else { + tempTimeLimit = nil + } + } + } + } + + // MARK: - Create Subtitle String + + private var subtitleString: String { + if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { + ServerTicks(maxRuntimeTicks).seconds.formatted(.hourMinute) + } else { + L10n.none + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/TimeRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/TimeRow.swift new file mode 100644 index 00000000..54b6c5b4 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/TimeRow.swift @@ -0,0 +1,36 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension AddTaskTriggerView { + + struct TimeRow: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + DatePicker( + L10n.time, + selection: Binding( + get: { + ServerTicks( + taskTriggerInfo.timeOfDayTicks ?? defaultTimeOfDayTicks + ).date + }, + set: { date in + taskTriggerInfo.timeOfDayTicks = ServerTicks(date: date).ticks + } + ), + displayedComponents: .hourAndMinute + ) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/TriggerTypeRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/TriggerTypeRow.swift new file mode 100644 index 00000000..b196ed6e --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/AddTaskTriggerView/Components/TriggerTypeRow.swift @@ -0,0 +1,74 @@ +// +// 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 TriggerTypeRow: View { + + @Binding + var taskTriggerInfo: TaskTriggerInfo + + var body: some View { + Picker( + L10n.type, + selection: Binding( + get: { + if let t = taskTriggerInfo.type { + return TaskTriggerType(rawValue: t) + } else { + return nil + } + }, + 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/EditScheduledTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift deleted file mode 100644 index 968ecb41..00000000 --- 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/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/DetailsSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/DetailsSection.swift new file mode 100644 index 00000000..40d868f4 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/DetailsSection.swift @@ -0,0 +1,23 @@ +// +// 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 EditServerTaskView { + + struct DetailsSection: View { + + let category: String + + var body: some View { + Section(L10n.details) { + TextPairView(leading: L10n.category, trailing: category) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastErrorSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastErrorSection.swift new file mode 100644 index 00000000..60ccd935 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastErrorSection.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 JellyfinAPI +import SwiftUI + +extension EditServerTaskView { + + struct LastErrorSection: View { + + let message: String + + var body: some View { + Section(L10n.errorDetails) { + Text(message) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastRunSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastRunSection.swift new file mode 100644 index 00000000..e280ca13 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/LastRunSection.swift @@ -0,0 +1,39 @@ +// +// 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 EditServerTaskView { + + struct LastRunSection: View { + + @CurrentDate + private var currentDate: Date + + let status: TaskCompletionStatus + let endTime: Date + + var body: some View { + Section(L10n.lastRun) { + + TextPairView( + leading: L10n.status, + trailing: status.displayTitle + ) + + TextPairView( + L10n.executed, + value: Text("\(endTime, format: .relative(presentation: .numeric, unitsStyle: .narrow))") + ) + .id(currentDate) + .monospacedDigit() + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift new file mode 100644 index 00000000..25669db7 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift @@ -0,0 +1,65 @@ +// +// 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 +// + +// +// 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 EditServerTaskView { + + struct ProgressSection: View { + + @ObservedObject + var observer: ServerTaskObserver + + var body: some View { + if observer.task.state == .running || observer.task.state == .cancelling { + Section(L10n.progress) { + if let status = observer.task.state { + TextPairView( + leading: L10n.status, + trailing: status.displayTitle + ) + } + + if let currentProgressPercentage = observer.task.currentProgressPercentage { + TextPairView( + L10n.progress, + value: Text("\(currentProgressPercentage / 100, format: .percent.precision(.fractionLength(1)))") + ) + .monospacedDigit() + } + + Button { + observer.send(.stop) + } label: { + HStack { + Text(L10n.stop) + + Spacer() + + Image(systemName: "stop.fill") + } + } + .foregroundStyle(.red) + } + } else { + Button(L10n.run) { + observer.send(.start) + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/TriggersSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/TriggersSection.swift new file mode 100644 index 00000000..4d5bfe5f --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/Sections/TriggersSection.swift @@ -0,0 +1,65 @@ +// +// 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 EditServerTaskView { + + struct TriggersSection: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @ObservedObject + var observer: ServerTaskObserver + + @State + private var isPresentingDeleteConfirmation: Bool = false + @State + private var selectedTrigger: TaskTriggerInfo? + + var body: some View { + Section(L10n.triggers) { + if let triggers = observer.task.triggers, triggers.isNotEmpty { + 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.addTrigger) { + router.route(to: \.addServerTaskTrigger, observer) + } + } + } + .confirmationDialog( + L10n.deleteTrigger, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let selectedTrigger { + observer.send(.removeTrigger(selectedTrigger)) + } + } + } message: { + Text(L10n.deleteTriggerConfirmationMessage) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/TriggerRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/TriggerRow.swift new file mode 100644 index 00000000..433404f7 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/Components/TriggerRow.swift @@ -0,0 +1,98 @@ +// +// 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 EditServerTaskView { + + struct TriggerRow: View { + + let taskTriggerInfo: TaskTriggerInfo + + // TODO: remove after `TaskTriggerType` is provided by SDK + + private var taskTriggerType: TaskTriggerType { + if let t = taskTriggerInfo.type, let type = TaskTriggerType(rawValue: t) { + return 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.timeLimitLabelWithValue( + ServerTicks(maxRuntimeTicks) + .seconds.formatted(.hourMinute) + ) + ) + } else { + Text(L10n.noRuntimeLimit) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: taskTriggerType.systemImage) + .backport + .fontWeight(.bold) + .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(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(timeOfDayTicks) + .date.formatted(date: .omitted, time: .shortened) + ) + } + case .interval: + if let intervalTicks = taskTriggerInfo.intervalTicks { + return L10n.everyInterval( + ServerTicks(intervalTicks) + .seconds.formatted(.hourMinute) + ) + } + case .startup: + return taskTriggerType.displayTitle + } + + return L10n.unknown + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/EditServerTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/EditServerTaskView.swift new file mode 100644 index 00000000..23f2dc10 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditServerTaskView/EditServerTaskView.swift @@ -0,0 +1,94 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct EditServerTaskView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @ObservedObject + var observer: ServerTaskObserver + + // MARK: - State Variables + + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingEventAlert = false + @State + private var error: JellyfinAPIError? + @State + private var selectedTrigger: TaskTriggerInfo? + + // MARK: - Body + + var body: some View { + List { + ListTitleSection( + observer.task.name ?? L10n.unknown, + description: observer.task.description + ) + + ProgressSection(observer: observer) + + if let category = observer.task.category { + DetailsSection(category: category) + } + + if let lastExecutionResult = observer.task.lastExecutionResult { + if let status = lastExecutionResult.status, let endTime = lastExecutionResult.endTimeUtc { + LastRunSection(status: status, endTime: endTime) + } + + if let errorMessage = lastExecutionResult.errorMessage { + LastErrorSection(message: errorMessage) + } + } + + TriggersSection(observer: observer) + } + .animation(.linear(duration: 0.2), value: observer.state) + .animation(.linear(duration: 0.1), value: observer.task.state) + .animation(.linear(duration: 0.1), value: observer.task.triggers) + .navigationTitle(L10n.task) + .topBarTrailing { + + if observer.backgroundStates.contains(.updatingTriggers) { + ProgressView() + } + + if let triggers = observer.task.triggers, triggers.isNotEmpty { + Button(L10n.add) { + UIDevice.impact(.light) + router.route(to: \.addServerTaskTrigger, observer) + } + .buttonStyle(.toolbarPill) + } + } + .onReceive(observer.events) { event in + switch event { + case let .error(eventError): + error = eventError + isPresentingEventAlert = true + } + } + .alert( + L10n.error, + isPresented: $isPresentingEventAlert, + presenting: error + ) { _ in + + } message: { error in + Text(error.localizedDescription) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView/ServerLogsView.swift similarity index 90% rename from Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView/ServerLogsView.swift index 376ecc25..180a72cb 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/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/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ServerTasksView/Components/DestructiveServerTask.swift similarity index 60% rename from Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/ServerTasksView/Components/DestructiveServerTask.swift index d71f5a29..2e35376e 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ServerTasksView/Components/DestructiveServerTask.swift @@ -10,38 +10,43 @@ import Defaults import JellyfinAPI import SwiftUI -extension ScheduledTasksView { +extension ServerTasksView { - struct ServerTaskButton: View { + struct DestructiveServerTask: View { + + @State + private var isPresented: Bool = false let title: String - let systemImage: String - let warningMessage: String - let isPresented: Binding + let systemName: String + let message: String let action: () -> Void // MARK: - Body var body: some View { Button(role: .destructive) { - isPresented.wrappedValue = true + isPresented = true } label: { HStack { Text(title) + .fontWeight(.semibold) Spacer() - Image(systemName: systemImage) + Image(systemName: systemName) + .backport + .fontWeight(.bold) } } .confirmationDialog( title, - isPresented: isPresented, - titleVisibility: .hidden + isPresented: $isPresented, + titleVisibility: .visible ) { Button(title, role: .destructive, action: action) } message: { - Text(warningMessage) + Text(message) } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ServerTasksView/Components/ServerTaskRow.swift similarity index 79% rename from Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/ServerTasksView/Components/ServerTaskRow.swift index 4c5f92b4..fe0add17 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ServerTasksView/Components/ServerTaskRow.swift @@ -10,9 +10,9 @@ import JellyfinAPI import Stinsen import SwiftUI -extension ScheduledTasksView { +extension ServerTasksView { - struct ScheduledTaskButton: View { + struct ServerTaskRow: View { @CurrentDate private var currentDate: Date @@ -41,22 +41,6 @@ extension ScheduledTasksView { } } - // MARK: - Task Status Section - - @ViewBuilder - private var statusView: some View { - switch observer.state { - case .running: - ProgressView(value: (observer.task.currentProgressPercentage ?? 0) / 100) - .progressViewStyle(.gauge(systemImage: "stop.fill")) - .transition(.opacity.combined(with: .scale).animation(.bouncy)) - default: - Image(systemName: "play.fill") - .foregroundStyle(.secondary) - .transition(.opacity.combined(with: .scale).animation(.bouncy)) - } - } - // MARK: - Task Status View @ViewBuilder @@ -97,8 +81,16 @@ extension ScheduledTasksView { Spacer() - statusView - .frame(width: 25, height: 25) + if observer.state == .running { + ProgressView(value: (observer.task.currentProgressPercentage ?? 0) / 100) + .progressViewStyle(.gauge) + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + .frame(width: 25, height: 25) + } + + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundStyle(.secondary) } } .animation(.linear(duration: 0.1), value: observer.state) @@ -122,7 +114,7 @@ extension ScheduledTasksView { .disabled(observer.task.state == .cancelling) Button(L10n.edit) { - router.route(to: \.editScheduledTask, observer) + router.route(to: \.editServerTask, observer) } } message: { if let description = observer.task.description { diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ServerTasksView/ServerTasksView.swift similarity index 78% rename from Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift rename to Swiftfin/Views/SettingsView/UserDashboardView/ServerTasksView/ServerTasksView.swift index 8a8b9cb7..717cf511 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ServerTasksView/ServerTasksView.swift @@ -12,18 +12,13 @@ import SwiftUI // TODO: refactor after socket implementation -struct ScheduledTasksView: View { +struct ServerTasksView: View { @EnvironmentObject private var router: SettingsCoordinator.Router - @State - private var isPresentingRestartConfirmation = false - @State - private var isPresentingShutdownConfirmation = false - @StateObject - private var viewModel = ScheduledTasksViewModel() + private var viewModel = ServerTasksViewModel() private let timer = Timer.publish(every: 5, on: .main, in: .common) .autoconnect() @@ -32,20 +27,18 @@ struct ScheduledTasksView: View { @ViewBuilder private var serverFunctions: some View { - ServerTaskButton( + DestructiveServerTask( title: L10n.restartServer, - systemImage: "arrow.clockwise", - warningMessage: L10n.restartWarning, - isPresented: $isPresentingRestartConfirmation + systemName: "arrow.clockwise", + message: L10n.restartWarning ) { viewModel.send(.restartApplication) } - ServerTaskButton( + DestructiveServerTask( title: L10n.shutdownServer, - systemImage: "power", - warningMessage: L10n.shutdownWarning, - isPresented: $isPresentingShutdownConfirmation + systemName: "power", + message: L10n.shutdownWarning ) { viewModel.send(.shutdownApplication) } @@ -71,7 +64,7 @@ struct ScheduledTasksView: View { ForEach(viewModel.tasks.keys, id: \.self) { category in Section(category) { ForEach(viewModel.tasks[category] ?? []) { task in - ScheduledTaskButton(observer: task) + ServerTaskRow(observer: task) } } } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index b5b1a146..cb6ec7a7 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -734,7 +734,7 @@ "save" = "Save"; /* Time Interval Help Text - Days */ -"days" = "days"; +"days" = "Days"; /* Section Title for Column Configuration */ "columns" = "Columns"; @@ -843,3 +843,183 @@ // Selects all available devices // Used to select all items in selection mode "selectAll" = "Select All"; + +// Logs Description - View +// Access the Jellyfin server logs for troubleshooting and monitoring purposes +// Describes the logs view in settings +"logsDescription" = "Access the Jellyfin server logs for troubleshooting and monitoring purposes."; + +/* Indicate a type */ +"type" = "Type"; + +// Day of Week - Section Label +// Specifies the day of the week for the trigger +// Label for the day of week section +"dayOfWeek" = "Day of Week"; + +// Time - Section Label +// Specifies the time for the trigger +// Label for the time section +"time" = "Time"; + +// Daily - Description +// Recurring trigger that runs daily +// Describes the daily trigger type +"daily" = "Daily"; + +// Interval - Description +// Recurring trigger based on time intervals +// Describes the interval trigger type +"interval" = "Interval"; + +// Weekly - Description +// Recurring trigger that runs weekly +// Describes the weekly trigger type +"weekly" = "Weekly"; + +// On Application Startup - Description +// Trigger that runs when the application starts +// Describes the startup trigger type +"onApplicationStartup" = "On application startup"; + +// Task Trigger Time Limit - Section Description +// Sets the maximum runtime (in hours) for this task trigger +// Description for the task trigger time limit section +"taskTriggerTimeLimit" = "Sets the maximum runtime (in hours) for this task trigger."; + +// Task Trigger Interval - Section Description +// Sets the duration (in minutes) in between task triggers +// Description for the task trigger interval section +"taskTriggerInterval" = "Sets the duration (in minutes) in between task triggers."; + +// Every - Label +// Used to select interval frequency +// Label for selecting interval frequency +"every" = "Every"; + +// Time Limit with Unit - Label +// Specifies time limit along with the unit +// Time limit label with descriptive unit +"timeLimitWithUnit" = "Time Limit (%@)"; + +// Time Limit - Section Label +// Specifies the time limit for the task +// Label for the time limit section +"timeLimit" = "Time Limit"; + +// Hours - Input Field Placeholder +// Placeholder for inputting hours +// Input field placeholder for hours +"hours" = "Hours"; + +// Minutes - Input Field Placeholder +// Placeholder for inputting minutes +// Input field placeholder for minutes +"minutes" = "Minutes"; + +// Add Trigger - Title +// Title for adding a new task trigger +// Title for adding a new task trigger +"addTrigger" = "Add trigger"; + +// Save - Button Label +// Button to save the current task trigger +// Save button label +"save" = "Save"; + +// Changes Not Saved - Alert Title +// Title for unsaved changes alert +// Title for the unsaved changes alert +"changesNotSaved" = "Changes not saved"; + +// Discard Changes - Button Label +// Button to discard unsaved changes +// Button label for discarding unsaved changes +"discardChanges" = "Discard Changes"; + +// Unsaved Changes Message - Alert +// Message for unsaved changes alert +// Alert message for unsaved changes +"unsavedChangesMessage" = "You have unsaved changes. Are you sure you want to discard them?"; + +// Delete Trigger - Confirmation Dialog Title +// Title for the delete trigger confirmation dialog +// Confirmation dialog title for deleting a trigger +"deleteTrigger" = "Delete Trigger"; + +// Delete Trigger Confirmation - Message +// Message for deleting a trigger confirmation dialog +// Confirmation dialog message for deleting a trigger +"deleteTriggerConfirmationMessage" = "Are you sure you want to delete this trigger? This action cannot be undone."; + +// Item At Item - Label +// Used to describe an item at another item +// Label for something at something else +"itemAtItem" = "%1$@ at %2$@"; + +// Every Interval - Label +// Describes an interval trigger with recurring time +// Label for interval trigger with recurring time +"everyInterval" = "Every %1$@"; + +// Time Limit Label with Value - Label +// Describes time limit with a value +// Label for time limit with value +"timeLimitLabelWithValue" = "Time limit: %1$@"; + +// Add - Button Label +// Button to add a new item +// Button label for adding a new item +"add" = "Add"; + +// Idle - Task State +// Describes the task state as idle +// Localized text for task state 'Idle' +"idle" = "Idle"; + +// Status - Section Title +// Title for the status section +// Section title for a status section +"status" = "Status"; + +// Error Details - Section Title +// Title for the error details section +// Section title for a task error details +"errorDetails" = "Error Details"; + +// Details - Section Title +// Title for the details section +// Section title for any details section +"details" = "Details"; + +// Triggers - Section Header +// Header for the scheduled task triggers section +// Section header for scheduled task triggers +"triggers" = "Triggers"; + +// Executed - Section Title +// Title for the task execution date section +// Section title for a task execution date +"executed" = "Executed"; + +// No Runtime Limit - Label +// Describes a task with no runtime limit +// No task trigger runtime limit set +"noRuntimeLimit" = "No runtime limit"; + +// API Key Created - Success Message +// A new Access Token was successfully created for the specified application +// Appears in success alert when a new API key is created +"serverTriggerCreated" = "A new trigger was created for '%1$@'."; + +// API Key Deleted - Success Message +// The Access Token was successfully deleted for the specified application +// Appears in success alert when an API key is deleted +"serverTriggerDeleted" = "The selected trigger was deleted from '%1$@'."; + +// Save - Button +// Confirms that something completed successfully or without error +// Appears in the views with eventful to indicate a task did not fail +"success" = "Success"; + +"triggerAlreadyExists" = "Trigger already exists";