diff --git a/Shared/Components/TextPairView.swift b/Shared/Components/TextPairView.swift index e56ff48b8..66c9ace9f 100644 --- a/Shared/Components/TextPairView.swift +++ b/Shared/Components/TextPairView.swift @@ -19,12 +19,12 @@ struct TextPairView: View { var body: some View { HStack { leading - .foregroundColor(.primary) + .foregroundStyle(.primary) Spacer() trailing - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index d27716e81..526b65e6f 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -61,6 +61,10 @@ final class SettingsCoordinator: NavigationCoordinatable { @Route(.push) var tasks = makeTasks @Route(.push) + var devices = makeDevices + @Route(.push) + var deviceDetails = makeDeviceDetails + @Route(.push) var editScheduledTask = makeEditScheduledTask @Route(.push) var serverLogs = makeServerLogs @@ -186,6 +190,16 @@ final class SettingsCoordinator: NavigationCoordinatable { ScheduledTasksView() } + @ViewBuilder + func makeDevices() -> some View { + DevicesView() + } + + @ViewBuilder + func makeDeviceDetails(device: DeviceInfo) -> some View { + DeviceDetailsView(device: device) + } + @ViewBuilder func makeEditScheduledTask(observer: ServerTaskObserver) -> some View { EditScheduledTaskView(observer: observer) diff --git a/Shared/Extensions/JellyfinAPI/DeviceInfo.swift b/Shared/Extensions/JellyfinAPI/DeviceInfo.swift new file mode 100644 index 000000000..90b30aa66 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DeviceInfo.swift @@ -0,0 +1,20 @@ +// +// 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 DeviceInfo { + + var device: DeviceType { + DeviceType( + client: appName, + deviceName: name + ) + } +} diff --git a/Shared/Extensions/URL.swift b/Shared/Extensions/URL.swift index 379beb49e..02ee9143c 100644 --- a/Shared/Extensions/URL.swift +++ b/Shared/Extensions/URL.swift @@ -33,6 +33,10 @@ extension URL { static let swiftfinGithubIssues: URL = URL(string: "https://github.com/jellyfin/Swiftfin/issues")! + static let jellyfinDocsDevices: URL = URL(string: "https://jellyfin.org/docs/general/server/devices")! + + static let jellyfinDocsTasks: URL = URL(string: "https://jellyfin.org/docs/general/server/tasks")! + func isDirectoryAndReachable() throws -> Bool { guard try resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true else { return false diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 4c51e9cc4..73588c8d8 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -32,6 +32,8 @@ internal enum L10n { internal static func airWithDate(_ p1: UnsafePointer) -> String { return L10n.tr("Localizable", "airWithDate", p1, fallback: "Airs %s") } + /// View all past and present devices that have connected. + internal static let allDevicesDescription = L10n.tr("Localizable", "allDevicesDescription", fallback: "View all past and present devices that have connected.") /// All Genres internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: "All Genres") /// All Media @@ -138,6 +140,8 @@ internal enum L10n { internal static let cancelling = L10n.tr("Localizable", "cancelling", fallback: "Cancelling...") /// Cannot connect to host internal static let cannotConnectToHost = L10n.tr("Localizable", "cannotConnectToHost", fallback: "Cannot connect to host") + /// Capabilities + internal static let capabilities = L10n.tr("Localizable", "capabilities", fallback: "Capabilities") /// CAST internal static let cast = L10n.tr("Localizable", "cast", fallback: "CAST") /// Cast & Crew @@ -214,6 +218,12 @@ internal enum L10n { internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: "Current Position") /// PlaybackCompatibility Custom Category internal static let custom = L10n.tr("Localizable", "custom", fallback: "Custom") + /// Custom Device Name + internal static let customDeviceName = L10n.tr("Localizable", "customDeviceName", fallback: "Custom Device Name") + /// Your custom device name '%1$@' has been saved. + internal static func customDeviceNameSaved(_ p1: Any) -> String { + return L10n.tr("Localizable", "customDeviceNameSaved", String(describing: p1), fallback: "Your custom device name '%1$@' has been saved.") + } /// Custom profile is Added to the Existing Profiles internal static let customDeviceProfileAdd = L10n.tr("Localizable", "customDeviceProfileAdd", fallback: "The custom device profiles will be added to the default Swiftfin device profiles") /// Device Profile Section Description @@ -236,6 +246,20 @@ internal enum L10n { internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") /// Server Detail View - Delete internal static let delete = L10n.tr("Localizable", "delete", fallback: "Delete") + /// Delete Device + internal static let deleteDevice = L10n.tr("Localizable", "deleteDevice", fallback: "Delete Device") + /// Failed to Delete Device + internal static let deleteDeviceFailed = L10n.tr("Localizable", "deleteDeviceFailed", fallback: "Failed to Delete Device") + /// Cannot delete a session from the same device (%1$@). + internal static func deleteDeviceSelfDeletion(_ p1: Any) -> String { + return L10n.tr("Localizable", "deleteDeviceSelfDeletion", String(describing: p1), fallback: "Cannot delete a session from the same device (%1$@).") + } + /// Are you sure you wish to delete this device? This session will be logged out. + internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out.") + /// Delete Selected Devices + internal static let deleteSelectedDevices = L10n.tr("Localizable", "deleteSelectedDevices", fallback: "Delete Selected Devices") + /// Are you sure you wish to delete all selected devices? All selected sessions will be logged out. + internal static let deleteSelectionDevicesWarning = L10n.tr("Localizable", "deleteSelectionDevicesWarning", fallback: "Are you sure you wish to delete all selected devices? All selected sessions will be logged out.") /// Server Detail View - Delete Server internal static let deleteServer = L10n.tr("Localizable", "deleteServer", fallback: "Delete Server") /// Delivery @@ -244,6 +268,8 @@ internal enum L10n { internal static let device = L10n.tr("Localizable", "device", fallback: "Device") /// Section Header for Device Profiles internal static let deviceProfile = L10n.tr("Localizable", "deviceProfile", fallback: "Device Profile") + /// Devices + internal static let devices = L10n.tr("Localizable", "devices", fallback: "Devices") /// PlaybackCompatibility DirectPlay Category internal static let direct = L10n.tr("Localizable", "direct", fallback: "Direct Play") /// DIRECTOR @@ -424,6 +450,8 @@ internal enum L10n { internal static let networking = L10n.tr("Localizable", "networking", fallback: "Networking") /// Network timed out internal static let networkTimedOut = L10n.tr("Localizable", "networkTimedOut", fallback: "Network timed out") + /// Never + internal static let never = L10n.tr("Localizable", "never", fallback: "Never") /// Message shown when a task has never run internal static let neverRun = L10n.tr("Localizable", "neverRun", fallback: "Never run") /// News @@ -440,6 +468,8 @@ internal enum L10n { internal static let nextUpDaysDescription = L10n.tr("Localizable", "nextUpDaysDescription", fallback: "Set the maximum amount of days a show should stay in the 'Next Up' list without watching it.") /// Settings Description for enabling rewatching in Next Up internal static let nextUpRewatch = L10n.tr("Localizable", "nextUpRewatch", fallback: "Rewatching in Next Up") + /// No + internal static let no = L10n.tr("Localizable", "no", fallback: "No") /// No Cast devices found.. internal static let noCastdevicesfound = L10n.tr("Localizable", "noCastdevicesfound", fallback: "No Cast devices found..") /// No Codec @@ -596,6 +626,8 @@ internal enum L10n { internal static let remainingTime = L10n.tr("Localizable", "remainingTime", fallback: "Remaining Time") /// Remove internal static let remove = L10n.tr("Localizable", "remove", fallback: "Remove") + /// Remove All + internal static let removeAll = L10n.tr("Localizable", "removeAll", fallback: "Remove All") /// Remove All Servers internal static let removeAllServers = L10n.tr("Localizable", "removeAllServers", fallback: "Remove All Servers") /// Remove All Users @@ -672,6 +704,8 @@ internal enum L10n { internal static let seekSlideGestureEnabled = L10n.tr("Localizable", "seekSlideGestureEnabled", fallback: "Seek Slide Gesture Enabled") /// See More internal static let seeMore = L10n.tr("Localizable", "seeMore", fallback: "See More") + /// Select All + internal static let selectAll = L10n.tr("Localizable", "selectAll", fallback: "Select All") /// Select Cast Destination internal static let selectCastDestination = L10n.tr("Localizable", "selectCastDestination", fallback: "Select Cast Destination") /// Series @@ -786,8 +820,18 @@ internal enum L10n { internal static let subtitlesDisclaimer = L10n.tr("Localizable", "subtitlesDisclaimer", fallback: "Settings only affect some subtitle types") /// Subtitle Size internal static let subtitleSize = L10n.tr("Localizable", "subtitleSize", fallback: "Subtitle Size") + /// Success + internal static let success = L10n.tr("Localizable", "success", fallback: "Success") /// Suggestions internal static let suggestions = L10n.tr("Localizable", "suggestions", fallback: "Suggestions") + /// Content Uploading + internal static let supportsContentUploading = L10n.tr("Localizable", "supportsContentUploading", fallback: "Content Uploading") + /// Media Control + internal static let supportsMediaControl = L10n.tr("Localizable", "supportsMediaControl", fallback: "Media Control") + /// Persistent Identifier + internal static let supportsPersistentIdentifier = L10n.tr("Localizable", "supportsPersistentIdentifier", fallback: "Persistent Identifier") + /// Sync + internal static let supportsSync = L10n.tr("Localizable", "supportsSync", fallback: "Sync") /// Switch User internal static let switchUser = L10n.tr("Localizable", "switchUser", fallback: "Switch User") /// Represents the system theme setting @@ -896,6 +940,8 @@ internal enum L10n { internal static let wip = L10n.tr("Localizable", "wip", fallback: "WIP") /// Yellow internal static let yellow = L10n.tr("Localizable", "yellow", fallback: "Yellow") + /// Yes + internal static let yes = L10n.tr("Localizable", "yes", fallback: "Yes") /// Your Favorites internal static let yourFavorites = L10n.tr("Localizable", "yourFavorites", fallback: "Your Favorites") } diff --git a/Shared/ViewModels/DevicesViewModel.swift b/Shared/ViewModels/DevicesViewModel.swift new file mode 100644 index 000000000..01de5d1e0 --- /dev/null +++ b/Shared/ViewModels/DevicesViewModel.swift @@ -0,0 +1,255 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Combine +import Foundation +import JellyfinAPI +import OrderedCollections +import SwiftUI + +final class DevicesViewModel: ViewModel, Eventful, Stateful { + + // MARK: Event + + enum Event { + case error(JellyfinAPIError) + case success + } + + // MARK: - Action + + enum Action: Equatable { + case getDevices + case setCustomName(id: String, newName: String) + case deleteDevices(ids: [String]) + } + + // MARK: - BackgroundState + + enum BackgroundState: Hashable { + case gettingDevices + case settingCustomName + case deletingDevices + } + + // MARK: - State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + } + + // MARK: Published Values + + var events: AnyPublisher { + eventSubject + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } + + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var devices: OrderedDictionary> = [:] + @Published + final var state: State = .initial + + @Published + private(set) var userID: String? + + private var deviceTask: AnyCancellable? + private var eventSubject: PassthroughSubject = .init() + + // MARK: - Initializer + + init(_ userID: String? = nil) { + self.userID = userID + } + + // MARK: - Respond to Action + + func respond(to action: Action) -> State { + switch action { + case .getDevices: + deviceTask?.cancel() + + backgroundStates.append(.gettingDevices) + + deviceTask = Task { [weak self] in + do { + try await self?.loadDevices( + userID: self?.userID + ) + await MainActor.run { + self?.state = .content + self?.eventSubject.send(.success) + } + } catch { + guard let self else { return } + await MainActor.run { + let jellyfinError = JellyfinAPIError(error.localizedDescription) + self.state = .error(jellyfinError) + self.eventSubject.send(.error(jellyfinError)) + } + } + + await MainActor.run { + self?.backgroundStates.remove(.gettingDevices) + } + } + .asAnyCancellable() + + return state + + case let .setCustomName(id, newName): + deviceTask?.cancel() + + backgroundStates.append(.settingCustomName) + + deviceTask = Task { [weak self] in + do { + try await self?.setCustomName(id: id, newName: newName) + await MainActor.run { + self?.state = .content + self?.eventSubject.send(.success) + } + } catch { + guard let self else { return } + await MainActor.run { + let jellyfinError = JellyfinAPIError(error.localizedDescription) + self.state = .error(jellyfinError) + self.eventSubject.send(.error(jellyfinError)) + } + } + + await MainActor.run { + self?.backgroundStates.remove(.settingCustomName) + } + } + .asAnyCancellable() + + return state + + case let .deleteDevices(ids): + deviceTask?.cancel() + + backgroundStates.append(.deletingDevices) + + deviceTask = Task { [weak self] in + do { + try await self?.deleteDevices(ids: ids) + await MainActor.run { + self?.state = .content + self?.eventSubject.send(.success) + } + } catch { + await MainActor.run { + let jellyfinError = JellyfinAPIError(error.localizedDescription) + self?.state = .error(jellyfinError) + self?.eventSubject.send(.error(jellyfinError)) + } + } + + await MainActor.run { + self?.backgroundStates.remove(.deletingDevices) + } + } + .asAnyCancellable() + + return state + } + } + + // MARK: - Load Devices + + private func loadDevices(userID: String?) async throws { + let request = Paths.getDevices(userID: userID) + let response = try await userSession.client.send(request) + + guard let devices = response.value.items else { + return + } + + await MainActor.run { + for device in devices { + guard let id = device.id else { continue } + + if let existingDevice = self.devices[id] { + existingDevice.value = device + } else { + self.devices[id] = BindingBox( + source: .init(get: { device }, set: { _ in }) + ) + } + } + + self.devices.sort { x, y in + let device0 = x.value.value + let device1 = y.value.value + return (device0?.dateLastActivity ?? Date()) > (device1?.dateLastActivity ?? Date()) + } + } + } + + // MARK: - Set Custom Name + + private func setCustomName(id: String, newName: String) async throws { + let request = Paths.updateDeviceOptions(id: id, DeviceOptionsDto(customName: newName)) + try await userSession.client.send(request) + + if let device = self.devices[id]?.value { + await MainActor.run { + self.devices[id]?.value?.name = newName + } + } + } + + // MARK: - Delete Device + + private func deleteDevice(id: String) async throws { + // Don't allow self-deletion + guard id != userSession.client.configuration.deviceID else { + return + } + + let request = Paths.deleteDevice(id: id) + try await userSession.client.send(request) + + await MainActor.run { + self.devices.removeValue(forKey: id) + } + } + + // MARK: - Delete Devices + + private func deleteDevices(ids: [String]) async throws { + guard ids.isNotEmpty else { + return + } + + // Don't allow self-deletion + let deviceIdsToDelete = ids.filter { $0 != userSession.client.configuration.deviceID } + + try await withThrowingTaskGroup(of: Void.self) { group in + for deviceId in deviceIdsToDelete { + group.addTask { + try await self.deleteDevice(id: deviceId) + } + } + + try await group.waitForAll() + } + + await MainActor.run { + self.devices = self.devices.filter { + !deviceIdsToDelete.contains($0.key) + } + } + } +} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 8c9c9bcef..10ca296a7 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -12,12 +12,18 @@ 4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; }; 4E0A8FFB2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; 4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; + 4E10C8112CC030CD0012CC9F /* DeviceDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */; }; + 4E10C8172CC0455A0012CC9F /* CompatibilitiesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */; }; + 4E10C8192CC045700012CC9F /* CustomDeviceNameSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */; }; + 4E10C81D2CC046610012CC9F /* UserSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C81C2CC0465F0012CC9F /* UserSection.swift */; }; 4E11805F2CBF52380077A588 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; 4E12F9172CBE9619006C217E /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; }; 4E16FD512C0183DB00110147 /* LetterPickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD502C0183DB00110147 /* LetterPickerButton.swift */; }; 4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD522C01840C00110147 /* LetterPickerBar.swift */; }; 4E16FD572C01A32700110147 /* LetterPickerOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */; }; 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 */; }; 4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */; }; @@ -82,6 +88,10 @@ 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 */; }; + 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 */; }; + 4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */; }; 4EF18B262CB9934C00343666 /* LibraryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B252CB9934700343666 /* LibraryRow.swift */; }; 4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */; }; 4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; }; @@ -919,6 +929,7 @@ E1DD55372B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; E1DD55382B6EE533007501C0 /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD55362B6EE533007501C0 /* Task.swift */; }; E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE2B492B97ECB900F6715F /* ErrorView.swift */; }; + E1DE64922CC6F0C900E423B6 /* DeviceSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */; }; E1DE84142B9531C1008CCE21 /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */; }; E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */; }; E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */; }; @@ -1028,10 +1039,15 @@ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = ""; }; + 4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailsView.swift; sourceTree = ""; }; + 4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilitiesSection.swift; sourceTree = ""; }; + 4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceNameSection.swift; sourceTree = ""; }; + 4E10C81C2CC0465F0012CC9F /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = ""; }; 4E12F9152CBE9615006C217E /* DeviceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceType.swift; sourceTree = ""; }; 4E16FD502C0183DB00110147 /* LetterPickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerButton.swift; sourceTree = ""; }; 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 = ""; }; 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = ""; }; @@ -1077,6 +1093,9 @@ 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 = ""; }; + 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 = ""; }; 4EF18B252CB9934700343666 /* LibraryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = ""; }; 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = ""; }; 4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = ""; }; @@ -1639,6 +1658,7 @@ E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddUserButton.swift; sourceTree = ""; }; E1DD55362B6EE533007501C0 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = ""; }; E1DE2B492B97ECB900F6715F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; + E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSection.swift; sourceTree = ""; }; E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIGestureRecognizer.swift; sourceTree = ""; }; E1E1643928BAC2EF00323B0A /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; @@ -1818,6 +1838,32 @@ path = ServerDiscovery; sourceTree = ""; }; + 4E10C80F2CC030B20012CC9F /* DeviceDetailsView */ = { + isa = PBXGroup; + children = ( + 4E10C8122CC044F30012CC9F /* Components */, + 4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */, + ); + path = DeviceDetailsView; + sourceTree = ""; + }; + 4E10C8122CC044F30012CC9F /* Components */ = { + isa = PBXGroup; + children = ( + 4E10C8132CC044FF0012CC9F /* Sections */, + ); + path = Components; + sourceTree = ""; + }; + 4E10C8132CC044FF0012CC9F /* Sections */ = { + isa = PBXGroup; + children = ( + 4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */, + 4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */, + ); + path = Sections; + sourceTree = ""; + }; 4E16FD4E2C0183B500110147 /* LetterPickerBar */ = { isa = PBXGroup; children = ( @@ -1876,8 +1922,11 @@ 4E63B9F52C8A5BEF00C25378 /* UserDashboardView */ = { isa = PBXGroup; children = ( + E1DE64902CC6F06C00E423B6 /* Components */, 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, + 4E10C80F2CC030B20012CC9F /* DeviceDetailsView */, + 4EED87492CBF824B002354D2 /* DevicesView */, E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */, 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */, E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, @@ -2009,6 +2058,23 @@ path = Components; sourceTree = ""; }; + 4EED87472CBF824B002354D2 /* Components */ = { + isa = PBXGroup; + children = ( + 4EED87462CBF824B002354D2 /* DeviceRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4EED87492CBF824B002354D2 /* DevicesView */ = { + isa = PBXGroup; + children = ( + 4EED87472CBF824B002354D2 /* Components */, + 4EED87482CBF824B002354D2 /* DevicesView.swift */, + ); + path = DevicesView; + sourceTree = ""; + }; 4EF18B232CB9932F00343666 /* PagingLibraryView */ = { isa = PBXGroup; children = ( @@ -2047,6 +2113,7 @@ 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */, E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, + 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */, E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, E113133928BEB71D00930F75 /* FilterViewModel.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, @@ -3575,6 +3642,7 @@ E1002B632793CEE700E47059 /* ChapterInfo.swift */, E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */, + 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */, 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */, 4E12F9152CBE9615006C217E /* DeviceType.swift */, 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */, @@ -3850,6 +3918,15 @@ path = LibraryParent; sourceTree = ""; }; + E1DE64902CC6F06C00E423B6 /* Components */ = { + isa = PBXGroup; + children = ( + E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */, + 4E10C81C2CC0465F0012CC9F /* UserSection.swift */, + ); + path = Components; + sourceTree = ""; + }; E1E5D54A2783E26100692DFE /* SettingsView */ = { isa = PBXGroup; children = ( @@ -4336,6 +4413,7 @@ E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */, E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */, + 4EED87502CBF84AD002354D2 /* DevicesViewModel.swift in Sources */, E14E9DF22BCF7A99004E3371 /* ItemLetter.swift in Sources */, E10B1EC82BD9AF6100A92EAF /* V2ServerModel.swift in Sources */, E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, @@ -4479,6 +4557,7 @@ E1DC9845296DECB600982F06 /* ProgressIndicator.swift in Sources */, E1C925F928875647002A7A66 /* LatestInLibraryView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, + 4E17498F2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */, E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */, E19D41B32BF2BFEF0082B8B2 /* URLSessionConfiguration.swift in Sources */, 4EDBDCD12CBDD6590033D347 /* SessionInfo.swift in Sources */, @@ -4708,6 +4787,7 @@ E1A1528A28FD22F600600579 /* TextPairView.swift in Sources */, E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */, E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, + 4E10C8112CC030CD0012CC9F /* DeviceDetailsView.swift in Sources */, E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */, E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */, @@ -4772,6 +4852,7 @@ C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, + 4E10C8172CC0455A0012CC9F /* CompatibilitiesSection.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */, E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */, @@ -4793,6 +4874,7 @@ E1EA09882BEE9CF3004CDE76 /* UserLocalSecurityView.swift in Sources */, E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */, E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */, + 4E10C81D2CC046610012CC9F /* UserSection.swift in Sources */, E19D41AA2BF077130082B8B2 /* Keychain.swift in Sources */, E1DE2B4A2B97ECB900F6715F /* ErrorView.swift in Sources */, E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */, @@ -4919,6 +5001,8 @@ 4EE141692C8BABDF0045B661 /* ProgressSection.swift in Sources */, E1A1528528FD191A00600579 /* TextPair.swift in Sources */, 6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */, + 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */, + 4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */, E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */, @@ -4927,7 +5011,9 @@ E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */, E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */, C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */, + 4E17498E2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */, 4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */, + 4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */, E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, E43918662AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */, @@ -4975,6 +5061,7 @@ 62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */, 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */, + 4E10C8192CC045700012CC9F /* CustomDeviceNameSection.swift in Sources */, 4EBE064D2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */, E1366A222C826DA700A36DED /* EditCustomDeviceProfileCoordinator.swift in Sources */, E1CCF12E28ABF989006CAC9E /* PosterDisplayType.swift in Sources */, @@ -5056,6 +5143,7 @@ 4E12F9172CBE9619006C217E /* DeviceType.swift in Sources */, E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */, 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */, + E1DE64922CC6F0C900E423B6 /* DeviceSection.swift in Sources */, E14EA15E2BF6F72900DE757A /* PhotoPicker.swift in Sources */, E19F6C5D28F5189300C5197E /* MediaStreamInfoView.swift in Sources */, E1D8429329340B8300D1041A /* Utilities.swift in Sources */, diff --git a/Swiftfin/Components/ListTitleSection.swift b/Swiftfin/Components/ListTitleSection.swift index 37476946f..88b4576b2 100644 --- a/Swiftfin/Components/ListTitleSection.swift +++ b/Swiftfin/Components/ListTitleSection.swift @@ -6,9 +6,11 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Defaults import SwiftUI // TODO: image +// TODO: rename struct ListTitleSection: View { @@ -64,3 +66,71 @@ extension ListTitleSection { ) } } + +struct InsetGroupedListHeader: View { + + @Default(.accentColor) + private var accentColor + + private let title: String + private let description: String? + private let onLearnMore: (() -> Void)? + + var body: some View { + Button { + onLearnMore?() + } label: { + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.secondarySystemBackground) + + VStack(alignment: .center, spacing: 10) { + + Text(title) + .font(.title3) + .fontWeight(.semibold) + + if let description { + Text(description) + .multilineTextAlignment(.center) + } + + if onLearnMore != nil { + Text("Learn More\u{2026}") + .foregroundStyle(accentColor) + } + } + .font(.subheadline) + .frame(maxWidth: .infinity) + .padding(16) + } + } + .foregroundStyle(.primary, .secondary) + } +} + +extension InsetGroupedListHeader { + + init( + _ title: String, + description: String? = nil + ) { + self.init( + title: title, + description: description, + onLearnMore: nil + ) + } + + init( + _ title: String, + description: String? = nil, + onLearnMore: @escaping () -> Void + ) { + self.init( + title: title, + description: description, + onLearnMore: onLearnMore + ) + } +} diff --git a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift index fc4a29ae8..7a04a3f8e 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -50,7 +50,7 @@ extension SettingsView { .clipShape(.circle) .frame(width: 50, height: 50) - Text(user.name ?? .emptyDash) + Text(user.name ?? L10n.unknown) .fontWeight(.semibold) .foregroundStyle(.primary) diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift index 806546a0b..5d1d56afa 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift @@ -13,9 +13,6 @@ import SwiftUIIntrospect struct ActiveSessionDetailView: View { - @CurrentDate - private var currentDate: Date - @EnvironmentObject private var router: SettingsCoordinator.Router @@ -27,37 +24,18 @@ struct ActiveSessionDetailView: View { @ViewBuilder private func idleContent(session: SessionInfo) -> some View { List { - Section(L10n.user) { - if let userID = session.userID { - SettingsView.UserProfileRow( - user: .init( - id: userID, - name: session.userName - ) - ) - } - - if let client = session.client { - TextPairView(leading: L10n.client, trailing: client) - } - - if let device = session.deviceName { - TextPairView(leading: L10n.device, trailing: device) - } - - if let applicationVersion = session.applicationVersion { - TextPairView(leading: L10n.version, trailing: applicationVersion) - } - - if let lastActivityDate = session.lastActivityDate { - TextPairView( - L10n.lastSeen, - value: Text(lastActivityDate, format: .relative(presentation: .numeric, unitsStyle: .narrow)) - ) - .id(currentDate) - .monospacedDigit() - } + if let userID = session.userID { + UserDashboardView.UserSection( + user: .init(id: userID, name: session.userName), + lastActivityDate: session.lastActivityDate + ) } + + UserDashboardView.DeviceSection( + client: session.client, + device: session.deviceName, + version: session.applicationVersion + ) } } @@ -81,29 +59,18 @@ struct ActiveSessionDetailView: View { ) } - Section(L10n.user) { - if let userID = session.userID { - SettingsView.UserProfileRow( - user: .init( - id: userID, - name: session.userName - ) - ) - } - - if let client = session.client { - TextPairView(leading: L10n.client, trailing: client) - } - - if let device = session.deviceName { - TextPairView(leading: L10n.device, trailing: device) - } - - if let applicationVersion = session.applicationVersion { - TextPairView(leading: L10n.version, trailing: applicationVersion) - } + if let userID = session.userID { + UserDashboardView.UserSection( + user: .init(id: userID, name: session.userName) + ) } + UserDashboardView.DeviceSection( + client: session.client, + device: session.deviceName, + version: session.applicationVersion + ) + // TODO: allow showing item stream details? // TODO: don't show codec changes on direct play? Section(L10n.streams) { diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift index 49507b85b..40b880480 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift @@ -67,6 +67,7 @@ struct ActiveSessionsView: View { DelayedProgressView() } } + .animation(.linear(duration: 0.2), value: viewModel.state) .navigationTitle(L10n.activeDevices) .onFirstAppear { viewModel.send(.refreshSessions) @@ -78,7 +79,6 @@ struct ActiveSessionsView: View { viewModel.send(.refreshSessions) } .topBarTrailing { - if viewModel.backgroundStates.contains(.gettingSessions) { ProgressView() } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/Components/DeviceSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/Components/DeviceSection.swift new file mode 100644 index 000000000..da52d48af --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/Components/DeviceSection.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 UserDashboardView { + + struct DeviceSection: View { + + let client: String? + let device: String? + let version: String? + + var body: some View { + Section(L10n.device) { + TextPairView( + leading: L10n.device, + trailing: device ?? L10n.unknown + ) + + TextPairView( + leading: L10n.client, + trailing: client ?? L10n.unknown + ) + + TextPairView( + leading: L10n.version, + trailing: version ?? L10n.unknown + ) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/Components/UserSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/Components/UserSection.swift new file mode 100644 index 000000000..c73d725b7 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/Components/UserSection.swift @@ -0,0 +1,46 @@ +// +// 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: if lastActivityDate not in same day, use date instead of relative + +extension UserDashboardView { + + struct UserSection: View { + + @CurrentDate + private var currentDate: Date + + private let user: UserDto + private let lastActivityDate: Date? + + init(user: UserDto, lastActivityDate: Date? = nil) { + self.user = user + self.lastActivityDate = lastActivityDate + } + + var body: some View { + Section(L10n.user) { + SettingsView.UserProfileRow( + user: user + ) + + if let lastActivityDate { + TextPairView( + L10n.lastSeen, + value: Text(lastActivityDate, format: .relative(presentation: .numeric, unitsStyle: .narrow)) + ) + .id(currentDate) + .monospacedDigit() + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/Components/Sections/CompatibilitiesSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/Components/Sections/CompatibilitiesSection.swift new file mode 100644 index 000000000..b24bebd17 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/Components/Sections/CompatibilitiesSection.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 DeviceDetailsView { + struct CapabilitiesSection: View { + var device: DeviceInfo + + var body: some View { + Section(L10n.capabilities) { + if let supportsContentUploading = device.capabilities?.isSupportsContentUploading { + TextPairView(leading: L10n.supportsContentUploading, trailing: supportsContentUploading ? L10n.yes : L10n.no) + } + + if let supportsMediaControl = device.capabilities?.isSupportsMediaControl { + TextPairView(leading: L10n.supportsMediaControl, trailing: supportsMediaControl ? L10n.yes : L10n.no) + } + + if let supportsPersistentIdentifier = device.capabilities?.isSupportsPersistentIdentifier { + TextPairView(leading: L10n.supportsPersistentIdentifier, trailing: supportsPersistentIdentifier ? L10n.yes : L10n.no) + } + + if let supportsSync = device.capabilities?.isSupportsSync { + TextPairView(leading: L10n.supportsSync, trailing: supportsSync ? L10n.yes : L10n.no) + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/Components/Sections/CustomDeviceNameSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/Components/Sections/CustomDeviceNameSection.swift new file mode 100644 index 000000000..3e1661b0b --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/Components/Sections/CustomDeviceNameSection.swift @@ -0,0 +1,28 @@ +// +// 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 DeviceDetailsView { + struct CustomDeviceNameSection: View { + @Binding + var customName: String + + // MARK: - Body + + var body: some View { + Section(L10n.customDeviceName) { + TextField( + L10n.name, + text: $customName + ) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/DeviceDetailsView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/DeviceDetailsView.swift new file mode 100644 index 000000000..bd68e2692 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DeviceDetailsView/DeviceDetailsView.swift @@ -0,0 +1,115 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +// TODO: Enable for CustomNames for Devices with SDK Changes + +struct DeviceDetailsView: View { + + @CurrentDate + private var currentDate: Date + + @State + private var temporaryCustomName: String + @State + private var error: Error? + @State + private var isPresentingError: Bool = false + @State + private var isPresentingSuccess: Bool = false + + @StateObject + private var viewModel: DevicesViewModel + + private let device: DeviceInfo + + // MARK: - Initializer + + init(device: DeviceInfo) { + self.device = device + // TODO: Enable with SDK Change + self.temporaryCustomName = device.name ?? "" // device.customName ?? device.name + _viewModel = StateObject(wrappedValue: DevicesViewModel(device.lastUserID)) + } + + // MARK: - Body + + var body: some View { + List { + if let lastUserID = device.lastUserID { + UserDashboardView.UserSection( + user: .init(id: lastUserID, name: device.lastUserName), + lastActivityDate: device.dateLastActivity + ) + } + + // TODO: Enable with SDK Change + // CustomDeviceNameSection(customName: $temporaryCustomName) + + UserDashboardView.DeviceSection( + client: device.appName, + device: device.name, + version: device.appVersion + ) + + CapabilitiesSection(device: device) + } + .navigationTitle(L10n.device) + .onReceive(viewModel.events) { event in + switch event { + case let .error(eventError): + UIDevice.feedback(.error) + error = eventError + isPresentingError = true + case .success: + UIDevice.feedback(.success) + isPresentingSuccess = true + } + } + .topBarTrailing { + if viewModel.backgroundStates.contains(.settingCustomName) { + ProgressView() + + // TODO: Enable with SDK Change + /* + Button(L10n.save) { + UIDevice.impact(.light) + if device.id != nil { + viewModel.send(.setCustomName( + id: device.id ?? "", + newName: temporaryCustomName + )) + } + } + .buttonStyle(.toolbarPill) + .disabled(temporaryCustomName == device.customName) + */ + } + } + .alert( + L10n.error.text, + isPresented: $isPresentingError, + presenting: error + ) { _ in + Button(L10n.dismiss, role: .cancel) + } message: { error in + Text(error.localizedDescription) + } + .alert( + L10n.success.text, + isPresented: $isPresentingSuccess + ) { + Button(L10n.dismiss, role: .cancel) + } message: { + Text(L10n.customDeviceNameSaved(temporaryCustomName)) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift new file mode 100644 index 000000000..fe43fc18b --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/Components/DeviceRow.swift @@ -0,0 +1,173 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Factory +import JellyfinAPI +import SwiftUI + +extension DevicesView { + + struct DeviceRow: View { + + @Default(.accentColor) + private var accentColor + + // MARK: - Environment Variables + + @Environment(\.colorScheme) + private var colorScheme + @Environment(\.isEditing) + private var isEditing + @Environment(\.isSelected) + private var isSelected + + @CurrentDate + private var currentDate: Date + + // MARK: - Observed Objects + + @ObservedObject + private var box: BindingBox + + // MARK: - Actions + + private let onSelect: () -> Void + private let onDelete: () -> Void + + // MARK: - Device Mapping + + private var deviceInfo: DeviceInfo { + box.value ?? .init() + } + + // MARK: - Initializer + + init( + box: BindingBox, + onSelect: @escaping () -> Void, + onDelete: @escaping () -> Void + ) { + self.box = box + self.onSelect = onSelect + self.onDelete = onDelete + } + + // MARK: - Label Styling + + private var labelForegroundStyle: some ShapeStyle { + guard isEditing else { return .primary } + + return isSelected ? .primary : .secondary + } + + // MARK: - Device Image View + + @ViewBuilder + private var deviceImage: some View { + ZStack { + deviceInfo.device.clientColor + + Image(deviceInfo.device.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40) + + if isEditing { + Color.black + .opacity(isSelected ? 0 : 0.5) + } + } + .squarePosterStyle() + .posterShadow() + .frame(width: 60, height: 60) + } + + // MARK: - Row Content + + @ViewBuilder + private var rowContent: some View { + HStack { + VStack(alignment: .leading) { + + Text(deviceInfo.name ?? L10n.unknown) + .font(.headline) + .lineLimit(2) + .multilineTextAlignment(.leading) + + TextPairView( + leading: L10n.user, + trailing: deviceInfo.lastUserName ?? L10n.unknown + ) + + TextPairView( + leading: L10n.client, + trailing: deviceInfo.appName ?? L10n.unknown + ) + + TextPairView( + L10n.lastSeen, + value: { + if let dateLastActivity = deviceInfo.dateLastActivity { + Text(dateLastActivity, format: .relative(presentation: .numeric, unitsStyle: .narrow)) + } else { + Text(L10n.never) + } + }() + ) + .id(currentDate) + .monospacedDigit() + } + .font(.subheadline) + .foregroundStyle(labelForegroundStyle, .secondary) + + Spacer() + + if isEditing, isSelected { + Image(systemName: "checkmark.circle.fill") + .resizable() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + + } else if isEditing { + Image(systemName: "circle") + .resizable() + .backport + .fontWeight(.bold) + .aspectRatio(1, contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Body + + var body: some View { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + deviceImage + } content: { + rowContent + .padding(.vertical, 8) + } + .onSelect(perform: onSelect) + .swipeActions { + Button( + L10n.delete, + systemImage: "trash", + action: onDelete + ) + .tint(.red) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift new file mode 100644 index 000000000..cfbc2de0c --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/DevicesView/DevicesView.swift @@ -0,0 +1,243 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +// TODO: Replace with CustomName when Available + +struct DevicesView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @State + private var isPresentingDeleteSelectionConfirmation = false + @State + private var isPresentingDeleteConfirmation = false + @State + private var isPresentingSelfDeleteError = false + @State + private var selectedDevices: Set = [] + @State + private var isEditing: Bool = false + + @StateObject + private var viewModel: DevicesViewModel + + // MARK: - Initializer + + init(userID: String? = nil) { + _viewModel = StateObject(wrappedValue: DevicesViewModel(userID)) + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + if viewModel.devices.isEmpty { + Text(L10n.none) + } else { + deviceListView + } + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.getDevices) + } + case .initial: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.devices) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isEditing) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if isEditing { + navigationBarSelectView + } + } + ToolbarItem(placement: .navigationBarTrailing) { + navigationBarEditView + } + } + .onFirstAppear { + viewModel.send(.getDevices) + } + .confirmationDialog( + L10n.deleteSelectedDevices, + isPresented: $isPresentingDeleteSelectionConfirmation, + titleVisibility: .visible + ) { + deleteSelectedDevicesConfirmationActions + } message: { + Text(L10n.deleteSelectionDevicesWarning) + } + .confirmationDialog( + L10n.deleteDevice, + isPresented: $isPresentingDeleteConfirmation, + titleVisibility: .visible + ) { + deleteDeviceConfirmationActions + } message: { + Text(L10n.deleteDeviceWarning) + } + .alert(L10n.deleteDeviceFailed, isPresented: $isPresentingSelfDeleteError) { + Button(L10n.ok, role: .cancel) {} + } message: { + Text(L10n.deleteDeviceSelfDeletion(viewModel.userSession.client.configuration.deviceName)) + } + } + + // MARK: - Device List View + + @ViewBuilder + private var deviceListView: some View { + VStack { + List { + InsetGroupedListHeader( + L10n.devices, + description: L10n.allDevicesDescription + ) { + UIApplication.shared.open(.jellyfinDocsDevices) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.vertical, 24) + + ForEach(viewModel.devices.keys, id: \.self) { id in + if let deviceBox = viewModel.devices[id] { + DeviceRow(box: deviceBox) { + if isEditing { + if selectedDevices.contains(id) { + selectedDevices.remove(id) + } else { + selectedDevices.insert(id) + } + } else if let selectedDevice = deviceBox.value { + router.route(to: \.deviceDetails, selectedDevice) + } + } onDelete: { + selectedDevices.removeAll() + selectedDevices.insert(id) + isPresentingDeleteConfirmation = true + } + .environment(\.isEditing, isEditing) + .environment(\.isSelected, selectedDevices.contains(id)) + .listRowSeparator(.hidden) + .listRowInsets(.zero) + } + } + } + .listStyle(.plain) + + if isEditing { + deleteDevicesButton + .edgePadding([.bottom, .horizontal]) + } + } + } + + // MARK: - Button to Delete Devices + + @ViewBuilder + private var deleteDevicesButton: some View { + Button { + isPresentingDeleteSelectionConfirmation = true + } label: { + ZStack { + Color.red + + Text(L10n.delete) + .font(.body.weight(.semibold)) + .foregroundStyle(selectedDevices.isNotEmpty ? .primary : .secondary) + + if selectedDevices.isEmpty { + Color.black + .opacity(0.5) + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(height: 50) + .frame(maxWidth: 400) + } + .disabled(selectedDevices.isEmpty) + .buttonStyle(.plain) + } + + // MARK: - Navigation Bar Edit Content + + @ViewBuilder + private var navigationBarEditView: some View { + if viewModel.backgroundStates.contains(.gettingDevices) { + ProgressView() + } else { + Button(isEditing ? L10n.cancel : L10n.edit) { + isEditing.toggle() + UIDevice.impact(.light) + if !isEditing { + selectedDevices.removeAll() + } + } + .buttonStyle(.toolbarPill) + } + } + + // MARK: - Navigation Bar Select/Remove All Content + + @ViewBuilder + private var navigationBarSelectView: some View { + let isAllSelected: Bool = selectedDevices.count == viewModel.devices.count + + Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { + if isAllSelected { + selectedDevices = [] + } else { + selectedDevices = Set(viewModel.devices.keys) + } + } + .buttonStyle(.toolbarPill) + .disabled(!isEditing) + } + + // MARK: - Delete Selected Devices Confirmation Actions + + @ViewBuilder + private var deleteSelectedDevicesConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.confirm, role: .destructive) { + viewModel.send(.deleteDevices(ids: Array(selectedDevices))) + isEditing = false + selectedDevices.removeAll() + } + } + + // MARK: - Delete Device Confirmation Actions + + @ViewBuilder + private var deleteDeviceConfirmationActions: some View { + Button(L10n.cancel, role: .cancel) {} + + Button(L10n.delete, role: .destructive) { + if let deviceToDelete = selectedDevices.first, selectedDevices.count == 1 { + if deviceToDelete == viewModel.userSession.client.configuration.deviceID { + isPresentingSelfDeleteError = true + } else { + viewModel.send(.deleteDevices(ids: [deviceToDelete])) + selectedDevices.removeAll() + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift index b9b68d8ec..8a8b9cb73 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift @@ -61,7 +61,7 @@ struct ScheduledTasksView: View { L10n.tasks, description: L10n.tasksDescription ) { - UIApplication.shared.open(URL(string: "https://jellyfin.org/docs/general/server/tasks")!) + UIApplication.shared.open(.jellyfinDocsTasks) } Section(L10n.server) { diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift index f939796b9..de263e3ca 100644 --- a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift +++ b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift @@ -28,6 +28,13 @@ struct UserDashboardView: View { router.route(to: \.activeSessions) } + Section("Activity") { + ChevronButton(L10n.devices) + .onSelect { + router.route(to: \.devices) + } + } + Section(L10n.advanced) { ChevronButton(L10n.logs) diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift index a96bb59b6..fb89622ee 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift @@ -87,7 +87,7 @@ struct ResetUserPasswordView: View { } .foregroundStyle(.red, .red.opacity(0.2)) } else { - ListRowButton("Save") { + ListRowButton(L10n.save) { focusedPassword = nil viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) } @@ -135,7 +135,7 @@ struct ResetUserPasswordView: View { Text(error.localizedDescription) } .alert( - "Success", + L10n.success, isPresented: $isPresentingSuccess ) { Button(L10n.dismiss, role: .cancel) { diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 50941eb59..b5b1a146e 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -738,3 +738,108 @@ /* Section Title for Column Configuration */ "columns" = "Columns"; + +// Devices - Section Header +// Title for the devices section in the Admin Dashboard +// Used as the header for the devices section +"devices" = "Devices"; + +// All Devices Description - Section Description +// Description for the all devices section in the Admin Dashboard +// Provides information about the devices connected to the server, including past and current connections +"allDevicesDescription" = "View all past and present devices that have connected."; + +// Delete Selected Devices - Button +// Button label for deleting all selected devices +// Used in the all devices section to delete all selected devices +"deleteSelectedDevices" = "Delete Selected Devices"; + +// Never - Filler Text +// Text displayed when something has never or will never occur +// Used as placeholder text for events that never happen +"never" = "Never"; + +// Delete Selected Devices Warning - Warning Message +// Warning message displayed when deleting all devices +// Informs the user about the consequences of deleting all devices +"deleteSelectionDevicesWarning" = "Are you sure you wish to delete all selected devices? All selected sessions will be logged out."; + +// Delete Device Warning - Warning Message +// Warning message displayed when deleting a single device +// Informs the user about the consequences of deleting the device +"deleteDeviceWarning" = "Are you sure you wish to delete this device? This session will be logged out."; + +// Delete Device - Action +// Message for deleting a single device in the all devices section +// Used in the confirmation dialog to delete a single device +"deleteDevice" = "Delete Device"; + +// Delete Device Self-Deletion - Error Message +// Error message when attempting to delete the current session's device +// Used to inform the user that they cannot delete their own session +"deleteDeviceSelfDeletion" = "Cannot delete a session from the same device (%1$@)."; + +// Delete Device Failed - Error Title +// Title for the alert when device deletion fails +// Displayed when the system fails to delete a device +"deleteDeviceFailed" = "Failed to Delete Device"; + +// Custom Device Name - Title +// Title for setting a custom device name +// Used in the custom device name section +"customDeviceName" = "Custom Device Name"; + +// Capabilities - Section Header +// Title for the section showing the device capabilities +// Displayed as the header for the device capabilities section +"capabilities" = "Capabilities"; + +// Supports Content Uploading - Label +// Indicates whether the device supports uploading content +// Used in the capabilities section to display if content uploading is supported +"supportsContentUploading" = "Content Uploading"; + +// Supports Media Control - Label +// Indicates whether the device supports media control (e.g., play, pause, stop) +// Used in the capabilities section to display media control capability +"supportsMediaControl" = "Media Control"; + +// Supports Persistent Identifier - Label +// Indicates whether the device supports a persistent identifier +// Used in the capabilities section to display persistent identifier support +"supportsPersistentIdentifier" = "Persistent Identifier"; + +// Supports Sync - Label +// Indicates whether the device suppoTestrts syncing content (e.g., media sync across devices) +// Used in the capabilities section to display sync capability +"supportsSync" = "Sync"; + +// Yes - Label +// Indicates that a capability is supported +// Used in the capabilities section as a positive response +"yes" = "Yes"; + +// No - Label +// Indicates that a capability is not supported +// Used in the capabilities section as a negative response +"no" = "No"; + +// Custom Device Name Saved - Label +// Confirms that the custom device name was saved successfully +// Used after successfully saving a custom device name +"customDeviceNameSaved" = "Your custom device name '%1$@' has been saved."; + +// Success - Label +// Indicates that an operation was successful +// Used as a confirmation for successful actions +"success" = "Success"; + +// Remove All - Button +// Deselects all currently selected devices +// Used to clear all selections in selection mode +"removeAll" = "Remove All"; + +// Select All - Button +// Selects all available devices +// Used to select all items in selection mode +"selectAll" = "Select All";