mirror of
https://github.com/jellyfin/Swiftfin.git
synced 2025-02-17 05:48:00 +00:00
[iOS] Admin Dashboard - Device Management (#1277)
* 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:
parent
11d6907735
commit
a04f97e1ba
@ -19,12 +19,12 @@ struct TextPairView: View {
|
||||
var body: some View {
|
||||
HStack {
|
||||
leading
|
||||
.foregroundColor(.primary)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
trailing
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
20
Shared/Extensions/JellyfinAPI/DeviceInfo.swift
Normal file
20
Shared/Extensions/JellyfinAPI/DeviceInfo.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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")
|
||||
}
|
||||
|
255
Shared/ViewModels/DevicesViewModel.swift
Normal file
255
Shared/ViewModels/DevicesViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 */,
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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";
|
||||
|
Loading…
x
Reference in New Issue
Block a user