[iOS] Admin Dashboard - Device Management (#1277)
Some checks failed
Build 🔨 / Build 🔨 (Swiftfin tvOS) (push) Has been cancelled
Build 🔨 / Build 🔨 (Swiftfin) (push) Has been cancelled

* Split out Devices Logic - Ready to go!

* Review Items + Reworking the deleteDevices logic to all use the same deleteDevice core + deleteDevices. Delete All Devices now just uses the more generic deleteDevices

* Allow Filtering on UserID for later usage on UserDetailView.

* Fully remove DeleteAll action in favor of Delete Devices. Change view to pass in the viewModel.devices as a 'Delete All' function

* DeviceDetailsView

* Section Split out, Localization, and cleanup.

* I guess I missed there on first upload.

* Initial Select All / Delete Devices logic. Checkbox options on the list. Hopefully this is good.

* Initial Review Item!

* Custom Device Name is now a field. Change DevicesViewModel to Eventful to capture updates

* Revised Device Interaction Buttons

* Remove unused Label.

* Make DeviceRow mirror UserRow. UpdateDevicesView to have DeleteButton when in EditMode. Also, it's EDITMODE not SELECTMODE... Finally, make sure the SelectedDevice and SelectedDevices are both empty if the user tries to delete themselves and fails. Change how the single device delete works to confirm deleting from an array still works as needed.

* wip

* Review Changes: 61b3716239

* Merge issues + testing again to make sure. Checks out.

* wip

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe 2024-10-21 15:10:25 -06:00 committed by GitHub
parent 11d6907735
commit a04f97e1ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1317 additions and 61 deletions

View File

@ -19,12 +19,12 @@ struct TextPairView: View {
var body: some View {
HStack {
leading
.foregroundColor(.primary)
.foregroundStyle(.primary)
Spacer()
trailing
.foregroundColor(.secondary)
.foregroundStyle(.secondary)
}
}
}

View File

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

View File

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

View File

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

View File

@ -32,6 +32,8 @@ internal enum L10n {
internal static func airWithDate(_ p1: UnsafePointer<CChar>) -> 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")
}

View File

@ -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<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
@Published
final var backgroundStates: OrderedSet<BackgroundState> = []
@Published
final var devices: OrderedDictionary<String, BindingBox<DeviceInfo?>> = [:]
@Published
final var state: State = .initial
@Published
private(set) var userID: String?
private var deviceTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .init()
// MARK: - Initializer
init(_ userID: String? = nil) {
self.userID = userID
}
// MARK: - Respond to Action
func respond(to action: Action) -> State {
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<DeviceInfo?>(
source: .init(get: { device }, set: { _ in })
)
}
}
self.devices.sort { x, y in
let device0 = x.value.value
let device1 = y.value.value
return (device0?.dateLastActivity ?? Date()) > (device1?.dateLastActivity ?? Date())
}
}
}
// MARK: - Set Custom Name
private func setCustomName(id: String, newName: String) async throws {
let request = Paths.updateDeviceOptions(id: id, DeviceOptionsDto(customName: newName))
try await userSession.client.send(request)
if let 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)
}
}
}
}

View File

@ -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 = "<group>"; };
4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = "<group>"; };
4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailsView.swift; sourceTree = "<group>"; };
4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilitiesSection.swift; sourceTree = "<group>"; };
4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceNameSection.swift; sourceTree = "<group>"; };
4E10C81C2CC0465F0012CC9F /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = "<group>"; };
4E12F9152CBE9615006C217E /* DeviceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceType.swift; sourceTree = "<group>"; };
4E16FD502C0183DB00110147 /* LetterPickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerButton.swift; sourceTree = "<group>"; };
4E16FD522C01840C00110147 /* LetterPickerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerBar.swift; sourceTree = "<group>"; };
4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerOrientation.swift; sourceTree = "<group>"; };
4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = "<group>"; };
4E182C9B2C94993200FBEFD5 /* ScheduledTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTasksView.swift; sourceTree = "<group>"; };
4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTaskButton.swift; sourceTree = "<group>"; };
4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = "<group>"; };
@ -1077,6 +1093,9 @@
4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = "<group>"; };
4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = "<group>"; };
4EE141682C8BABDF0045B661 /* ProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressSection.swift; sourceTree = "<group>"; };
4EED87462CBF824B002354D2 /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = "<group>"; };
4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = "<group>"; };
4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = "<group>"; };
4EF18B252CB9934700343666 /* LibraryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = "<group>"; };
4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = "<group>"; };
4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = "<group>"; };
@ -1639,6 +1658,7 @@
E1DD20402BE1EB8C00C0DE51 /* AddUserButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddUserButton.swift; sourceTree = "<group>"; };
E1DD55362B6EE533007501C0 /* Task.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Task.swift; sourceTree = "<group>"; };
E1DE2B492B97ECB900F6715F /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSection.swift; sourceTree = "<group>"; };
E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = "<group>"; };
E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIGestureRecognizer.swift; sourceTree = "<group>"; };
E1E1643928BAC2EF00323B0A /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
@ -1818,6 +1838,32 @@
path = ServerDiscovery;
sourceTree = "<group>";
};
4E10C80F2CC030B20012CC9F /* DeviceDetailsView */ = {
isa = PBXGroup;
children = (
4E10C8122CC044F30012CC9F /* Components */,
4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */,
);
path = DeviceDetailsView;
sourceTree = "<group>";
};
4E10C8122CC044F30012CC9F /* Components */ = {
isa = PBXGroup;
children = (
4E10C8132CC044FF0012CC9F /* Sections */,
);
path = Components;
sourceTree = "<group>";
};
4E10C8132CC044FF0012CC9F /* Sections */ = {
isa = PBXGroup;
children = (
4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */,
4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */,
);
path = Sections;
sourceTree = "<group>";
};
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 = "<group>";
};
4EED87472CBF824B002354D2 /* Components */ = {
isa = PBXGroup;
children = (
4EED87462CBF824B002354D2 /* DeviceRow.swift */,
);
path = Components;
sourceTree = "<group>";
};
4EED87492CBF824B002354D2 /* DevicesView */ = {
isa = PBXGroup;
children = (
4EED87472CBF824B002354D2 /* Components */,
4EED87482CBF824B002354D2 /* DevicesView.swift */,
);
path = DevicesView;
sourceTree = "<group>";
};
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 = "<group>";
};
E1DE64902CC6F06C00E423B6 /* Components */ = {
isa = PBXGroup;
children = (
E1DE64912CC6F0C900E423B6 /* DeviceSection.swift */,
4E10C81C2CC0465F0012CC9F /* UserSection.swift */,
);
path = Components;
sourceTree = "<group>";
};
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 */,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<DeviceInfo?>
// MARK: - Actions
private let onSelect: () -> Void
private let onDelete: () -> Void
// MARK: - Device Mapping
private var deviceInfo: DeviceInfo {
box.value ?? .init()
}
// MARK: - Initializer
init(
box: BindingBox<DeviceInfo?>,
onSelect: @escaping () -> Void,
onDelete: @escaping () -> Void
) {
self.box = box
self.onSelect = onSelect
self.onDelete = onDelete
}
// 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)
}
}
}
}

View File

@ -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<String> = []
@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()
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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