diff --git a/Shared/Components/ProgressBar.swift b/Shared/Components/ProgressBar.swift index 3eaf5c2df..5b505e7cb 100644 --- a/Shared/Components/ProgressBar.swift +++ b/Shared/Components/ProgressBar.swift @@ -8,24 +8,80 @@ import SwiftUI -// TODO: see if animation is correct here or should be in caller views - +// TODO: remove and replace with below struct ProgressBar: View { + @State + private var contentSize: CGSize = .zero + let progress: CGFloat var body: some View { - ZStack(alignment: .leading) { - Capsule() - .foregroundColor(.secondary) - .opacity(0.2) - - Capsule() - .mask(alignment: .leading) { - Rectangle() - .scaleEffect(x: progress, anchor: .leading) + Capsule() + .foregroundStyle(.secondary) + .opacity(0.2) + .overlay(alignment: .leading) { + Capsule() + .mask(alignment: .leading) { + Rectangle() + } + .frame(width: contentSize.width * progress) + .foregroundStyle(.primary) + } + .trackingSize($contentSize) + } +} + +// TODO: fix capsule with low progress + +extension ProgressViewStyle where Self == PlaybackProgressViewStyle { + + static var playback: Self { .init(secondaryProgress: nil) } + + static func playback(secondaryProgress: Double?) -> Self { + .init(secondaryProgress: secondaryProgress) + } +} + +struct PlaybackProgressViewStyle: ProgressViewStyle { + + @State + private var contentSize: CGSize = .zero + + let secondaryProgress: Double? + + func makeBody(configuration: Configuration) -> some View { + Capsule() + .foregroundStyle(.secondary) + .opacity(0.2) + .overlay(alignment: .leading) { + ZStack(alignment: .leading) { + + if let secondaryProgress { + Capsule() + .mask(alignment: .leading) { + Rectangle() + } + .frame(width: contentSize.width * clamp(secondaryProgress, min: 0, max: 1)) + .foregroundStyle(.tertiary) + } + + Capsule() + .mask(alignment: .leading) { + Rectangle() + } + .frame(width: contentSize.width * (configuration.fractionCompleted ?? 0)) + .foregroundStyle(.primary) } - } - .animation(.linear(duration: 0.1), value: progress) + } + .trackingSize($contentSize) } } + +// #Preview { +// ProgressView(value: 0.3) +// .progressViewStyle(.SwiftfinLinear(secondaryProgress: 0.3)) +// .frame(height: 8) +// .padding(.horizontal, 10) +// .foregroundStyle(.primary, .secondary, .orange) +// } diff --git a/Shared/Components/TextPairView.swift b/Shared/Components/TextPairView.swift index 66f26828a..e56ff48b8 100644 --- a/Shared/Components/TextPairView.swift +++ b/Shared/Components/TextPairView.swift @@ -13,17 +13,17 @@ import SwiftUI struct TextPairView: View { - let leading: String - let trailing: String + private let leading: Text + private let trailing: Text var body: some View { HStack { - Text(leading) + leading .foregroundColor(.primary) Spacer() - Text(trailing) + trailing .foregroundColor(.secondary) } } @@ -33,8 +33,22 @@ extension TextPairView { init(_ textPair: TextPair) { self.init( - leading: textPair.title, - trailing: textPair.subtitle + leading: Text(textPair.title), + trailing: Text(textPair.subtitle) + ) + } + + init(leading: String, trailing: String) { + self.init( + leading: Text(leading), + trailing: Text(trailing) + ) + } + + init(_ title: String, value: @autoclosure () -> Text) { + self.init( + leading: Text(title), + trailing: value() ) } } diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index f4f1d2d5c..c5d36cf46 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -6,6 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import PulseUI import Stinsen import SwiftUI @@ -43,12 +44,27 @@ final class SettingsCoordinator: NavigationCoordinatable { @Route(.push) var indicatorSettings = makeIndicatorSettings @Route(.push) - var serverDetail = makeServerDetail + var serverConnection = makeServerConnection @Route(.push) var videoPlayerSettings = makeVideoPlayerSettings @Route(.push) var customDeviceProfileSettings = makeCustomDeviceProfileSettings + @Route(.push) + var userDashboard = makeUserDashboard + @Route(.push) + var activeSessions = makeActiveSessions + @Route(.push) + var activeDeviceDetails = makeActiveDeviceDetails + @Route(.modal) + var itemOverviewView = makeItemOverviewView + @Route(.push) + var tasks = makeTasks + @Route(.push) + var editScheduledTask = makeEditScheduledTask + @Route(.push) + var serverLogs = makeServerLogs + @Route(.modal) var editCustomDeviceProfile = makeEditCustomDeviceProfile @Route(.modal) @@ -142,10 +158,46 @@ final class SettingsCoordinator: NavigationCoordinatable { } @ViewBuilder - func makeServerDetail(server: ServerState) -> some View { + func makeServerConnection(server: ServerState) -> some View { EditServerView(server: server) } + @ViewBuilder + func makeUserDashboard() -> some View { + UserDashboardView() + } + + @ViewBuilder + func makeActiveSessions() -> some View { + ActiveSessionsView() + } + + @ViewBuilder + func makeActiveDeviceDetails(box: BindingBox) -> some View { + ActiveSessionDetailView(box: box) + } + + func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator { + ItemOverviewView(item: item) + } + } + + @ViewBuilder + func makeTasks() -> some View { + ScheduledTasksView() + } + + @ViewBuilder + func makeEditScheduledTask(observer: ServerTaskObserver) -> some View { + EditScheduledTaskView(observer: observer) + } + + @ViewBuilder + func makeServerLogs() -> some View { + ServerLogsView() + } + func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View { OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases) .navigationTitle(L10n.filters) diff --git a/Shared/Extensions/FormatStyle.swift b/Shared/Extensions/FormatStyle.swift index 344d845cf..e251934a2 100644 --- a/Shared/Extensions/FormatStyle.swift +++ b/Shared/Extensions/FormatStyle.swift @@ -22,3 +22,33 @@ extension FormatStyle where Self == HourMinuteFormatStyle { static var hourMinute: HourMinuteFormatStyle { HourMinuteFormatStyle() } } + +struct RunTimeFormatStyle: FormatStyle { + + private var negate: Bool = false + + var negated: RunTimeFormatStyle { + mutating(\.negate, with: true) + } + + func format(_ value: Int) -> String { + let hours = value / 3600 + let minutes = (value % 3600) / 60 + let seconds = value % 3600 % 60 + + let hourText = hours > 0 ? String(hours).appending(":") : "" + let minutesText = hours > 0 ? String(minutes).leftPad(maxWidth: 2, with: "0").appending(":") : String(minutes) + .appending(":") + let secondsText = String(seconds).leftPad(maxWidth: 2, with: "0") + + return hourText + .appending(minutesText) + .appending(secondsText) + .prepending("-", if: negate) + } +} + +extension FormatStyle where Self == RunTimeFormatStyle { + + static var runtime: RunTimeFormatStyle { RunTimeFormatStyle() } +} diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift index 235abcb60..18e56f43a 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift @@ -37,6 +37,8 @@ extension BaseItemDto: Poster { var systemImage: String { switch type { + case .audio, .musicAlbum: + "music.note" case .boxSet: "film.stack" case .channel, .tvChannel, .liveTvChannel, .program: @@ -93,4 +95,13 @@ extension BaseItemDto: Poster { [imageSource(.backdrop, maxWidth: maxWidth)] } } + + func squareImageSources(maxWidth: CGFloat?) -> [ImageSource] { + switch type { + case .audio, .musicAlbum: + [imageSource(.primary, maxWidth: maxWidth)] + default: + [] + } + } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index dd7472c49..2e40fc881 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift @@ -253,4 +253,16 @@ extension BaseItemDto { return L10n.play } + + var parentTitle: String? { + switch type { + case .audio: + album + case .episode: + seriesName + case .program: nil + default: + nil + } + } } diff --git a/Shared/Extensions/JellyfinAPI/JellyfinClient.swift b/Shared/Extensions/JellyfinAPI/JellyfinClient.swift index 81af9d38b..bd238dc70 100644 --- a/Shared/Extensions/JellyfinAPI/JellyfinClient.swift +++ b/Shared/Extensions/JellyfinAPI/JellyfinClient.swift @@ -13,7 +13,7 @@ import UIKit extension JellyfinClient { - func fullURL(with request: Request) -> URL? { + func fullURL(with request: Request, queryAPIKey: Bool = false) -> URL? { guard let path = request.url?.path else { return configuration.url } guard let fullPath = fullURL(with: path) else { return nil } @@ -21,6 +21,10 @@ extension JellyfinClient { components.queryItems = request.query?.map { URLQueryItem(name: $0.0, value: $0.1) } ?? [] + if queryAPIKey, let accessToken { + components.queryItems?.append(.init(name: "api_key", value: accessToken)) + } + return components.url ?? fullPath } diff --git a/Shared/Extensions/JellyfinAPI/PlayMethod.swift b/Shared/Extensions/JellyfinAPI/PlayMethod.swift new file mode 100644 index 000000000..f18618c40 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/PlayMethod.swift @@ -0,0 +1,25 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import SwiftUI + +extension PlayMethod: Displayable { + + var displayTitle: String { + switch self { + case .transcode: + return L10n.transcode + case .directStream: + return L10n.directStream + case .directPlay: + return L10n.directPlay + } + } +} diff --git a/Shared/Extensions/JellyfinAPI/PlayerStateInfo.swift b/Shared/Extensions/JellyfinAPI/PlayerStateInfo.swift new file mode 100644 index 000000000..f0058a7c7 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/PlayerStateInfo.swift @@ -0,0 +1,18 @@ +// +// 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 PlayerStateInfo { + + var positionSeconds: Int? { + guard let positionTicks else { return nil } + return positionTicks / 10_000_000 + } +} diff --git a/Shared/Extensions/JellyfinAPI/TaskCompletionStatus.swift b/Shared/Extensions/JellyfinAPI/TaskCompletionStatus.swift new file mode 100644 index 000000000..a48b0777c --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/TaskCompletionStatus.swift @@ -0,0 +1,26 @@ +// +// 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 TaskCompletionStatus: Displayable { + + var displayTitle: String { + switch self { + case .completed: + return L10n.taskCompleted + case .failed: + return L10n.taskFailed + case .cancelled: + return L10n.taskCancelled + case .aborted: + return L10n.taskAborted + } + } +} diff --git a/Shared/Extensions/JellyfinAPI/TranscodeReason.swift b/Shared/Extensions/JellyfinAPI/TranscodeReason.swift new file mode 100644 index 000000000..6049e2efd --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/TranscodeReason.swift @@ -0,0 +1,104 @@ +// +// 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 +import SwiftUI + +extension TranscodeReason: Displayable, SystemImageable { + + var displayTitle: String { + switch self { + case .containerNotSupported: + return L10n.containerNotSupported + case .videoCodecNotSupported: + return L10n.videoCodecNotSupported + case .audioCodecNotSupported: + return L10n.audioCodecNotSupported + case .subtitleCodecNotSupported: + return L10n.subtitleCodecNotSupported + case .audioIsExternal: + return L10n.audioIsExternal + case .secondaryAudioNotSupported: + return L10n.secondaryAudioNotSupported + case .videoProfileNotSupported: + return L10n.videoProfileNotSupported + case .videoLevelNotSupported: + return L10n.videoLevelNotSupported + case .videoResolutionNotSupported: + return L10n.videoResolutionNotSupported + case .videoBitDepthNotSupported: + return L10n.videoBitDepthNotSupported + case .videoFramerateNotSupported: + return L10n.videoFramerateNotSupported + case .refFramesNotSupported: + return L10n.refFramesNotSupported + case .anamorphicVideoNotSupported: + return L10n.anamorphicVideoNotSupported + case .interlacedVideoNotSupported: + return L10n.interlacedVideoNotSupported + case .audioChannelsNotSupported: + return L10n.audioChannelsNotSupported + case .audioProfileNotSupported: + return L10n.audioProfileNotSupported + case .audioSampleRateNotSupported: + return L10n.audioSampleRateNotSupported + case .audioBitDepthNotSupported: + return L10n.audioBitDepthNotSupported + case .containerBitrateExceedsLimit: + return L10n.containerBitrateExceedsLimit + case .videoBitrateNotSupported: + return L10n.videoBitrateNotSupported + case .audioBitrateNotSupported: + return L10n.audioBitrateNotSupported + case .unknownVideoStreamInfo: + return L10n.unknownVideoStreamInfo + case .unknownAudioStreamInfo: + return L10n.unknownAudioStreamInfo + case .directPlayError: + return L10n.directPlayError + case .videoRangeTypeNotSupported: + return L10n.videoRangeTypeNotSupported + } + } + + var systemImage: String { + switch self { + case .containerNotSupported, + .containerBitrateExceedsLimit: + return "shippingbox" + case .audioCodecNotSupported, + .audioIsExternal, + .secondaryAudioNotSupported, + .audioChannelsNotSupported, + .audioProfileNotSupported, + .audioSampleRateNotSupported, + .audioBitDepthNotSupported, + .audioBitrateNotSupported, + .unknownAudioStreamInfo: + return "speaker.wave.2" + case .videoCodecNotSupported, + .videoProfileNotSupported, + .videoLevelNotSupported, + .videoResolutionNotSupported, + .videoBitDepthNotSupported, + .videoFramerateNotSupported, + .refFramesNotSupported, + .anamorphicVideoNotSupported, + .interlacedVideoNotSupported, + .videoBitrateNotSupported, + .unknownVideoStreamInfo, + .videoRangeTypeNotSupported: + return "photo.tv" + case .subtitleCodecNotSupported: + return "captions.bubble" + default: + return "questionmark.app" + } + } +} diff --git a/Shared/Extensions/OrderedDictionary.swift b/Shared/Extensions/OrderedDictionary.swift new file mode 100644 index 000000000..302c1326d --- /dev/null +++ b/Shared/Extensions/OrderedDictionary.swift @@ -0,0 +1,16 @@ +// +// 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 OrderedCollections + +extension OrderedDictionary { + + var isNotEmpty: Bool { + !isEmpty + } +} diff --git a/Shared/Extensions/Text.swift b/Shared/Extensions/Text.swift new file mode 100644 index 000000000..c49808884 --- /dev/null +++ b/Shared/Extensions/Text.swift @@ -0,0 +1,16 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension Text { + + init(_ content: some Displayable) { + self.init(verbatim: "\(content.displayTitle)") + } +} diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 6339353db..bbaebcb24 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -108,6 +108,16 @@ extension View { } } + // TODO: consolidate handling + @ViewBuilder + func squarePosterStyle(contentMode: ContentMode = .fill) -> some View { + aspectRatio(1.0, contentMode: contentMode) + #if !os(tvOS) + .posterBorder(ratio: 0.0375, of: \.width) + .cornerRadius(ratio: 0.0375, of: \.width) + #endif + } + func posterBorder(ratio: CGFloat, of side: KeyPath) -> some View { modifier(OnSizeChangedModifier { size in overlay { diff --git a/Shared/Objects/CurrentDate.swift b/Shared/Objects/CurrentDate.swift new file mode 100644 index 000000000..4d4b840c5 --- /dev/null +++ b/Shared/Objects/CurrentDate.swift @@ -0,0 +1,56 @@ +// +// 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 SwiftUI + +/// A property wrapper that publishes the current +/// date at periodic intervals +@propertyWrapper +struct CurrentDate: DynamicProperty { + + @ObservedObject + private var observable: CurrentDataObserver + + var projectedValue: Binding { + $observable.currentDate + } + + var wrappedValue: Date { + observable.currentDate + } + + init(interval: TimeInterval = 1) { + self.observable = .init(interval: interval) + } + + mutating func update() { + _observable.update() + } +} + +extension CurrentDate { + + class CurrentDataObserver: ObservableObject { + + @Published + var currentDate: Date = .now + + private var publisher: AnyCancellable? + + init(interval: TimeInterval) { + publisher = Timer.publish(every: 1, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + if let self { + self.currentDate = .now + } + } + } + } +} diff --git a/Shared/Objects/Poster.swift b/Shared/Objects/Poster.swift index 4d21bda19..6d255a965 100644 --- a/Shared/Objects/Poster.swift +++ b/Shared/Objects/Poster.swift @@ -28,6 +28,10 @@ protocol Poster: Displayable, Hashable, Identifiable, SystemImageable { func cinematicImageSources( maxWidth: CGFloat? ) -> [ImageSource] + + func squareImageSources( + maxWidth: CGFloat? + ) -> [ImageSource] } extension Poster { @@ -57,4 +61,10 @@ extension Poster { ) -> [ImageSource] { [] } + + func squareImageSources( + maxWidth: CGFloat? + ) -> [ImageSource] { + [] + } } diff --git a/Shared/Objects/PosterDisplayType.swift b/Shared/Objects/PosterDisplayType.swift index 71ef72324..4eaff949b 100644 --- a/Shared/Objects/PosterDisplayType.swift +++ b/Shared/Objects/PosterDisplayType.swift @@ -9,6 +9,7 @@ import Defaults import SwiftUI +// TODO: think about what to do for square (music) enum PosterDisplayType: String, CaseIterable, Displayable, Storable, SystemImageable { case landscape diff --git a/Shared/Objects/UserAccessPolicy.swift b/Shared/Objects/UserAccessPolicy.swift index ce8117180..96c23bf50 100644 --- a/Shared/Objects/UserAccessPolicy.swift +++ b/Shared/Objects/UserAccessPolicy.swift @@ -10,6 +10,7 @@ import Foundation // TODO: require remote sign in every time // - actually found to be a bit difficult? +// TODO: rename to not confuse with server access/UserDto enum UserAccessPolicy: String, CaseIterable, Codable, Displayable { diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index bc7b8d496..36978a8a6 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -18,10 +18,14 @@ internal enum L10n { internal static let accentColorDescription = L10n.tr("Localizable", "accentColorDescription", fallback: "Some views may need an app restart to update.") /// Accessibility internal static let accessibility = L10n.tr("Localizable", "accessibility", fallback: "Accessibility") + /// Active Devices + internal static let activeDevices = L10n.tr("Localizable", "activeDevices", fallback: "Active Devices") /// Add Server internal static let addServer = L10n.tr("Localizable", "addServer", fallback: "Add Server") /// Add URL internal static let addURL = L10n.tr("Localizable", "addURL", fallback: "Add URL") + /// Administration + internal static let administration = L10n.tr("Localizable", "administration", fallback: "Administration") /// Advanced internal static let advanced = L10n.tr("Localizable", "advanced", fallback: "Advanced") /// Airs %s @@ -34,6 +38,8 @@ internal enum L10n { internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") /// All Servers internal static let allServers = L10n.tr("Localizable", "allServers", fallback: "All Servers") + /// Anamorphic video is not supported + internal static let anamorphicVideoNotSupported = L10n.tr("Localizable", "anamorphicVideoNotSupported", fallback: "Anamorphic video is not supported") /// Appearance internal static let appearance = L10n.tr("Localizable", "appearance", fallback: "Appearance") /// App Icon @@ -46,8 +52,22 @@ internal enum L10n { internal static let audio = L10n.tr("Localizable", "audio", fallback: "Audio") /// Audio & Captions internal static let audioAndCaptions = L10n.tr("Localizable", "audioAndCaptions", fallback: "Audio & Captions") + /// The audio bit depth is not supported + internal static let audioBitDepthNotSupported = L10n.tr("Localizable", "audioBitDepthNotSupported", fallback: "The audio bit depth is not supported") + /// The audio bitrate is not supported + internal static let audioBitrateNotSupported = L10n.tr("Localizable", "audioBitrateNotSupported", fallback: "The audio bitrate is not supported") + /// The number of audio channels is not supported + internal static let audioChannelsNotSupported = L10n.tr("Localizable", "audioChannelsNotSupported", fallback: "The number of audio channels is not supported") + /// The audio codec is not supported + internal static let audioCodecNotSupported = L10n.tr("Localizable", "audioCodecNotSupported", fallback: "The audio codec is not supported") + /// The audio track is external and requires transcoding + internal static let audioIsExternal = L10n.tr("Localizable", "audioIsExternal", fallback: "The audio track is external and requires transcoding") /// Audio Offset internal static let audioOffset = L10n.tr("Localizable", "audioOffset", fallback: "Audio Offset") + /// The audio profile is not supported + internal static let audioProfileNotSupported = L10n.tr("Localizable", "audioProfileNotSupported", fallback: "The audio profile is not supported") + /// The audio sample rate is not supported + internal static let audioSampleRateNotSupported = L10n.tr("Localizable", "audioSampleRateNotSupported", fallback: "The audio sample rate is not supported") /// Audio Track internal static let audioTrack = L10n.tr("Localizable", "audioTrack", fallback: "Audio Track") /// Authorize @@ -112,12 +132,18 @@ internal enum L10n { internal static let buttons = L10n.tr("Localizable", "buttons", fallback: "Buttons") /// Cancel internal static let cancel = L10n.tr("Localizable", "cancel", fallback: "Cancel") + /// Cancelled + internal static let canceled = L10n.tr("Localizable", "canceled", fallback: "Cancelled") + /// Cancelling... + 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") /// CAST internal static let cast = L10n.tr("Localizable", "cast", fallback: "CAST") /// Cast & Crew internal static let castAndCrew = L10n.tr("Localizable", "castAndCrew", fallback: "Cast & Crew") + /// Category + internal static let category = L10n.tr("Localizable", "category", fallback: "Category") /// Change Server internal static let changeServer = L10n.tr("Localizable", "changeServer", fallback: "Change Server") /// Channels @@ -132,6 +158,8 @@ internal enum L10n { internal static let cinematicBackground = L10n.tr("Localizable", "cinematicBackground", fallback: "Cinematic Background") /// Cinematic Views internal static let cinematicViews = L10n.tr("Localizable", "cinematicViews", fallback: "Cinematic Views") + /// Client + internal static let client = L10n.tr("Localizable", "client", fallback: "Client") /// Close internal static let close = L10n.tr("Localizable", "close", fallback: "Close") /// Closed Captions @@ -152,6 +180,8 @@ internal enum L10n { internal static let compatibility = L10n.tr("Localizable", "compatibility", fallback: "Compatibility") /// Most Compatible internal static let compatible = L10n.tr("Localizable", "compatible", fallback: "Most Compatible") + /// Confirm + internal static let confirm = L10n.tr("Localizable", "confirm", fallback: "Confirm") /// Confirm Close internal static let confirmClose = L10n.tr("Localizable", "confirmClose", fallback: "Confirm Close") /// Connect @@ -166,6 +196,10 @@ internal enum L10n { internal static let connectToJellyfinServerStart = L10n.tr("Localizable", "connectToJellyfinServerStart", fallback: "Connect to a Jellyfin server to get started") /// Connect to Server internal static let connectToServer = L10n.tr("Localizable", "connectToServer", fallback: "Connect to Server") + /// The container bitrate exceeds the allowed limit + internal static let containerBitrateExceedsLimit = L10n.tr("Localizable", "containerBitrateExceedsLimit", fallback: "The container bitrate exceeds the allowed limit") + /// The container format is not supported + internal static let containerNotSupported = L10n.tr("Localizable", "containerNotSupported", fallback: "The container format is not supported") /// Containers internal static let containers = L10n.tr("Localizable", "containers", fallback: "Containers") /// Continue @@ -190,6 +224,10 @@ internal enum L10n { internal static let customProfile = L10n.tr("Localizable", "customProfile", fallback: "Custom Profile") /// Dark internal static let dark = L10n.tr("Localizable", "dark", fallback: "Dark") + /// Dashboard + internal static let dashboard = L10n.tr("Localizable", "dashboard", fallback: "Dashboard") + /// Perform administrative tasks for your Jellyfin server. + internal static let dashboardDescription = L10n.tr("Localizable", "dashboardDescription", fallback: "Perform administrative tasks for your Jellyfin server.") /// Default Scheme internal static let defaultScheme = L10n.tr("Localizable", "defaultScheme", fallback: "Default Scheme") /// Delete @@ -198,12 +236,20 @@ internal enum L10n { internal static let deleteServer = L10n.tr("Localizable", "deleteServer", fallback: "Delete Server") /// Delivery internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery") + /// Device + internal static let device = L10n.tr("Localizable", "device", fallback: "Device") /// Device Profile internal static let deviceProfile = L10n.tr("Localizable", "deviceProfile", fallback: "Device Profile") /// Direct Play internal static let direct = L10n.tr("Localizable", "direct", fallback: "Direct Play") /// DIRECTOR internal static let director = L10n.tr("Localizable", "director", fallback: "DIRECTOR") + /// Direct Play + internal static let directPlay = L10n.tr("Localizable", "directPlay", fallback: "Direct Play") + /// An error occurred during direct play + internal static let directPlayError = L10n.tr("Localizable", "directPlayError", fallback: "An error occurred during direct play") + /// Direct Stream + internal static let directStream = L10n.tr("Localizable", "directStream", fallback: "Direct Stream") /// Disabled internal static let disabled = L10n.tr("Localizable", "disabled", fallback: "Disabled") /// Discovered Servers @@ -214,6 +260,8 @@ internal enum L10n { internal static let displayOrder = L10n.tr("Localizable", "displayOrder", fallback: "Display order") /// Downloads internal static let downloads = L10n.tr("Localizable", "downloads", fallback: "Downloads") + /// Edit + internal static let edit = L10n.tr("Localizable", "edit", fallback: "Edit") /// Edit Jump Lengths internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: "Edit Jump Lengths") /// Edit Server @@ -248,6 +296,10 @@ internal enum L10n { internal static let filterResults = L10n.tr("Localizable", "filterResults", fallback: "Filter Results") /// Filters internal static let filters = L10n.tr("Localizable", "filters", fallback: "Filters") + /// %@fps + internal static func fpsWithString(_ p1: Any) -> String { + return L10n.tr("Localizable", "fpsWithString", String(describing: p1), fallback: "%@fps") + } /// Genres internal static let genres = L10n.tr("Localizable", "genres", fallback: "Genres") /// Gestures @@ -264,10 +316,16 @@ internal enum L10n { internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators") /// Information internal static let information = L10n.tr("Localizable", "information", fallback: "Information") + /// Interlaced video is not supported + internal static let interlacedVideoNotSupported = L10n.tr("Localizable", "interlacedVideoNotSupported", fallback: "Interlaced video is not supported") /// Inverted Dark internal static let invertedDark = L10n.tr("Localizable", "invertedDark", fallback: "Inverted Dark") /// Inverted Light internal static let invertedLight = L10n.tr("Localizable", "invertedLight", fallback: "Inverted Light") + /// %1$@ / %2$@ + internal static func itemOverItem(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "itemOverItem", String(describing: p1), String(describing: p2), fallback: "%1$@ / %2$@") + } /// Items internal static let items = L10n.tr("Localizable", "items", fallback: "Items") /// Jellyfin @@ -294,6 +352,14 @@ internal enum L10n { internal static let larger = L10n.tr("Localizable", "larger", fallback: "Larger") /// Largest internal static let largest = L10n.tr("Localizable", "largest", fallback: "Largest") + /// Last run + internal static let lastRun = L10n.tr("Localizable", "lastRun", fallback: "Last run") + /// Last ran %@ + internal static func lastRunTime(_ p1: Any) -> String { + return L10n.tr("Localizable", "lastRunTime", String(describing: p1), fallback: "Last ran %@") + } + /// Last Seen + internal static let lastSeen = L10n.tr("Localizable", "lastSeen", fallback: "Last Seen") /// Latest %@ internal static func latestWithString(_ p1: Any) -> String { return L10n.tr("Localizable", "latestWithString", String(describing: p1), fallback: "Latest %@") @@ -330,6 +396,8 @@ internal enum L10n { internal static let media = L10n.tr("Localizable", "media", fallback: "Media") /// Menu Buttons internal static let menuButtons = L10n.tr("Localizable", "menuButtons", fallback: "Menu Buttons") + /// Method + internal static let method = L10n.tr("Localizable", "method", fallback: "Method") /// Missing internal static let missing = L10n.tr("Localizable", "missing", fallback: "Missing") /// Missing Items @@ -350,6 +418,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 run + internal static let neverRun = L10n.tr("Localizable", "neverRun", fallback: "Never run") /// News internal static let news = L10n.tr("Localizable", "news", fallback: "News") /// Next @@ -376,6 +446,8 @@ internal enum L10n { internal static let noResults = L10n.tr("Localizable", "noResults", fallback: "No results.") /// Normal internal static let normal = L10n.tr("Localizable", "normal", fallback: "Normal") + /// No session + internal static let noSession = L10n.tr("Localizable", "noSession", fallback: "No session") /// N/A internal static let notAvailableSlash = L10n.tr("Localizable", "notAvailableSlash", fallback: "N/A") /// Type: %@ not implemented yet :( @@ -390,6 +462,8 @@ internal enum L10n { internal static let ok = L10n.tr("Localizable", "ok", fallback: "Ok") /// 1 user internal static let oneUser = L10n.tr("Localizable", "oneUser", fallback: "1 user") + /// Online + internal static let online = L10n.tr("Localizable", "online", fallback: "Online") /// On Now internal static let onNow = L10n.tr("Localizable", "onNow", fallback: "On Now") /// Operating System @@ -496,6 +570,8 @@ internal enum L10n { internal static let recommended = L10n.tr("Localizable", "recommended", fallback: "Recommended") /// Red internal static let red = L10n.tr("Localizable", "red", fallback: "Red") + /// The number of reference frames is not supported + internal static let refFramesNotSupported = L10n.tr("Localizable", "refFramesNotSupported", fallback: "The number of reference frames is not supported") /// Refresh internal static let refresh = L10n.tr("Localizable", "refresh", fallback: "Refresh") /// Regular @@ -526,6 +602,10 @@ internal enum L10n { internal static let resetAppSettings = L10n.tr("Localizable", "resetAppSettings", fallback: "Reset App Settings") /// Reset User Settings internal static let resetUserSettings = L10n.tr("Localizable", "resetUserSettings", fallback: "Reset User Settings") + /// Restart Server + internal static let restartServer = L10n.tr("Localizable", "restartServer", fallback: "Restart Server") + /// Are you sure you want to restart the server? + internal static let restartWarning = L10n.tr("Localizable", "restartWarning", fallback: "Are you sure you want to restart the server?") /// Resume internal static let resume = L10n.tr("Localizable", "resume", fallback: "Resume") /// Resume 5 Second Offset @@ -542,8 +622,16 @@ internal enum L10n { internal static let retry = L10n.tr("Localizable", "retry", fallback: "Retry") /// Right internal static let `right` = L10n.tr("Localizable", "right", fallback: "Right") + /// Run + internal static let run = L10n.tr("Localizable", "run", fallback: "Run") + /// Running... + internal static let running = L10n.tr("Localizable", "running", fallback: "Running...") /// Runtime internal static let runtime = L10n.tr("Localizable", "runtime", fallback: "Runtime") + /// Scan All Libraries + internal static let scanAllLibraries = L10n.tr("Localizable", "scanAllLibraries", fallback: "Scan All Libraries") + /// Scheduled Tasks + internal static let scheduledTasks = L10n.tr("Localizable", "scheduledTasks", fallback: "Scheduled Tasks") /// Scrub Current Time internal static let scrubCurrentTime = L10n.tr("Localizable", "scrubCurrentTime", fallback: "Scrub Current Time") /// Search @@ -560,6 +648,8 @@ internal enum L10n { } /// Seasons internal static let seasons = L10n.tr("Localizable", "seasons", fallback: "Seasons") + /// Secondary audio is not supported + internal static let secondaryAudioNotSupported = L10n.tr("Localizable", "secondaryAudioNotSupported", fallback: "Secondary audio is not supported") /// See All internal static let seeAll = L10n.tr("Localizable", "seeAll", fallback: "See All") /// Seek Slide Gesture Enabled @@ -586,10 +676,14 @@ internal enum L10n { internal static let serverDetails = L10n.tr("Localizable", "serverDetails", fallback: "Server Details") /// Server Information internal static let serverInformation = L10n.tr("Localizable", "serverInformation", fallback: "Server Information") + /// Server Logs + internal static let serverLogs = L10n.tr("Localizable", "serverLogs", fallback: "Server Logs") /// Servers internal static let servers = L10n.tr("Localizable", "servers", fallback: "Servers") /// Server URL internal static let serverURL = L10n.tr("Localizable", "serverURL", fallback: "Server URL") + /// Session + internal static let session = L10n.tr("Localizable", "session", fallback: "Session") /// Settings internal static let settings = L10n.tr("Localizable", "settings", fallback: "Settings") /// Show Cast & Crew @@ -616,6 +710,10 @@ internal enum L10n { internal static let showUnwatched = L10n.tr("Localizable", "showUnwatched", fallback: "Show Unwatched") /// Show Watched internal static let showWatched = L10n.tr("Localizable", "showWatched", fallback: "Show Watched") + /// Shutdown Server + internal static let shutdownServer = L10n.tr("Localizable", "shutdownServer", fallback: "Shutdown Server") + /// Are you sure you want to shutdown the server? + internal static let shutdownWarning = L10n.tr("Localizable", "shutdownWarning", fallback: "Are you sure you want to shutdown the server?") /// Signed in as %@ internal static func signedInAsWithString(_ p1: Any) -> String { return L10n.tr("Localizable", "signedInAsWithString", String(describing: p1), fallback: "Signed in as %@") @@ -648,12 +746,18 @@ internal enum L10n { internal static let specialFeatures = L10n.tr("Localizable", "specialFeatures", fallback: "Special Features") /// Sports internal static let sports = L10n.tr("Localizable", "sports", fallback: "Sports") + /// Stop + internal static let stop = L10n.tr("Localizable", "stop", fallback: "Stop") + /// Streams + internal static let streams = L10n.tr("Localizable", "streams", fallback: "Streams") /// STUDIO internal static let studio = L10n.tr("Localizable", "studio", fallback: "STUDIO") /// Studios internal static let studios = L10n.tr("Localizable", "studios", fallback: "Studios") /// Subtitle internal static let subtitle = L10n.tr("Localizable", "subtitle", fallback: "Subtitle") + /// The subtitle codec is not supported + internal static let subtitleCodecNotSupported = L10n.tr("Localizable", "subtitleCodecNotSupported", fallback: "The subtitle codec is not supported") /// Subtitle Color internal static let subtitleColor = L10n.tr("Localizable", "subtitleColor", fallback: "Subtitle Color") /// Subtitle Font @@ -676,6 +780,20 @@ internal enum L10n { internal static let systemControlGesturesEnabled = L10n.tr("Localizable", "systemControlGesturesEnabled", fallback: "System Control Gestures Enabled") /// Tags internal static let tags = L10n.tr("Localizable", "tags", fallback: "Tags") + /// Task + internal static let task = L10n.tr("Localizable", "task", fallback: "Task") + /// Aborted + internal static let taskAborted = L10n.tr("Localizable", "taskAborted", fallback: "Aborted") + /// Cancelled + internal static let taskCancelled = L10n.tr("Localizable", "taskCancelled", fallback: "Cancelled") + /// Completed + internal static let taskCompleted = L10n.tr("Localizable", "taskCompleted", fallback: "Completed") + /// Failed + internal static let taskFailed = L10n.tr("Localizable", "taskFailed", fallback: "Failed") + /// Tasks + internal static let tasks = L10n.tr("Localizable", "tasks", fallback: "Tasks") + /// Tasks are operations that are scheduled to run periodically or can be triggered manually. + internal static let tasksDescription = L10n.tr("Localizable", "tasksDescription", fallback: "Tasks are operations that are scheduled to run periodically or can be triggered manually.") /// Test Size internal static let testSize = L10n.tr("Localizable", "testSize", fallback: "Test Size") /// Timestamp @@ -686,6 +804,10 @@ internal enum L10n { internal static let tooManyRedirects = L10n.tr("Localizable", "tooManyRedirects", fallback: "Too Many Redirects") /// Trailing Value internal static let trailingValue = L10n.tr("Localizable", "trailingValue", fallback: "Trailing Value") + /// Transcode + internal static let transcode = L10n.tr("Localizable", "transcode", fallback: "Transcode") + /// Transcode Reason(s) + internal static let transcodeReasons = L10n.tr("Localizable", "transcodeReasons", fallback: "Transcode Reason(s)") /// Transition internal static let transition = L10n.tr("Localizable", "transition", fallback: "Transition") /// Try again @@ -704,8 +826,12 @@ internal enum L10n { internal static let unauthorizedUser = L10n.tr("Localizable", "unauthorizedUser", fallback: "Unauthorized user") /// Unknown internal static let unknown = L10n.tr("Localizable", "unknown", fallback: "Unknown") + /// The audio stream information is unknown + internal static let unknownAudioStreamInfo = L10n.tr("Localizable", "unknownAudioStreamInfo", fallback: "The audio stream information is unknown") /// Unknown Error internal static let unknownError = L10n.tr("Localizable", "unknownError", fallback: "Unknown Error") + /// The video stream information is unknown + internal static let unknownVideoStreamInfo = L10n.tr("Localizable", "unknownVideoStreamInfo", fallback: "The video stream information is unknown") /// Unplayed internal static let unplayed = L10n.tr("Localizable", "unplayed", fallback: "Unplayed") /// URL @@ -728,10 +854,26 @@ internal enum L10n { internal static let version = L10n.tr("Localizable", "version", fallback: "Version") /// Video internal static let video = L10n.tr("Localizable", "video", fallback: "Video") + /// The video bit depth is not supported + internal static let videoBitDepthNotSupported = L10n.tr("Localizable", "videoBitDepthNotSupported", fallback: "The video bit depth is not supported") + /// The video bitrate is not supported + internal static let videoBitrateNotSupported = L10n.tr("Localizable", "videoBitrateNotSupported", fallback: "The video bitrate is not supported") + /// The video codec is not supported + internal static let videoCodecNotSupported = L10n.tr("Localizable", "videoCodecNotSupported", fallback: "The video codec is not supported") + /// The video framerate is not supported + internal static let videoFramerateNotSupported = L10n.tr("Localizable", "videoFramerateNotSupported", fallback: "The video framerate is not supported") + /// The video level is not supported + internal static let videoLevelNotSupported = L10n.tr("Localizable", "videoLevelNotSupported", fallback: "The video level is not supported") /// Video Player internal static let videoPlayer = L10n.tr("Localizable", "videoPlayer", fallback: "Video Player") /// Video Player Type internal static let videoPlayerType = L10n.tr("Localizable", "videoPlayerType", fallback: "Video Player Type") + /// The video profile is not supported + internal static let videoProfileNotSupported = L10n.tr("Localizable", "videoProfileNotSupported", fallback: "The video profile is not supported") + /// The video range type is not supported + internal static let videoRangeTypeNotSupported = L10n.tr("Localizable", "videoRangeTypeNotSupported", fallback: "The video range type is not supported") + /// The video resolution is not supported + internal static let videoResolutionNotSupported = L10n.tr("Localizable", "videoResolutionNotSupported", fallback: "The video resolution is not supported") /// Who's watching? internal static let whosWatching = L10n.tr("Localizable", "WhosWatching", fallback: "Who's watching?") /// WIP diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift index 642804b12..2d994e1f8 100644 --- a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift @@ -89,6 +89,14 @@ extension StoredValues.Keys { ) } + static var accessPolicy: Key { + CurrentUserKey( + "currentUserAccessPolicy", + domain: "currentUserAccessPolicy", + default: .none + ) + } + static func libraryDisplayType(parentID: String?) -> Key { CurrentUserKey( parentID, diff --git a/Shared/SwiftfinStore/SwiftinStore+UserState.swift b/Shared/SwiftfinStore/SwiftinStore+UserState.swift index 5acbd1d4d..4737478bd 100644 --- a/Shared/SwiftfinStore/SwiftinStore+UserState.swift +++ b/Shared/SwiftfinStore/SwiftinStore+UserState.swift @@ -64,6 +64,10 @@ extension UserState { } } + var isAdministrator: Bool { + data.policy?.isAdministrator ?? false + } + var pinHint: String { get { StoredValues[.User.pinHint(id: id)] diff --git a/Shared/ViewModels/ActiveSessionsViewModel.swift b/Shared/ViewModels/ActiveSessionsViewModel.swift new file mode 100644 index 000000000..d2dae1244 --- /dev/null +++ b/Shared/ViewModels/ActiveSessionsViewModel.swift @@ -0,0 +1,172 @@ +// +// 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 ActiveSessionsViewModel: ViewModel, Stateful { + + // MARK: - Action + + enum Action: Equatable { + case getSessions + case refreshSessions + } + + // MARK: - BackgroundState + + enum BackgroundState: Hashable { + case gettingSessions + } + + // MARK: - State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + } + + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var sessions: OrderedDictionary> = [:] + @Published + final var state: State = .initial + + private let activeWithinSeconds: Int = 960 + private var sessionTask: AnyCancellable? + + func respond(to action: Action) -> State { + switch action { + case .getSessions: + sessionTask?.cancel() + + sessionTask = Task { [weak self] in + await MainActor.run { + let _ = self?.backgroundStates.append(.gettingSessions) + } + + do { + try await self?.updateSessions() + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + + await MainActor.run { + let _ = self?.backgroundStates.remove(.gettingSessions) + } + } + .asAnyCancellable() + + return state + case .refreshSessions: + sessionTask?.cancel() + + sessionTask = Task { [weak self] in + await MainActor.run { + self?.state = .initial + } + + do { + try await self?.updateSessions() + + guard let self else { return } + + await MainActor.run { + self.state = .content + } + } catch { + guard let self else { return } + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .initial + } + } + + private func updateSessions() async throws { + var parameters = Paths.GetSessionsParameters() + parameters.activeWithinSeconds = activeWithinSeconds + + let request = Paths.getSessions(parameters: parameters) + let response = try await userSession.client.send(request) + + let removedSessionIDs = sessions.keys.filter { !response.value.map(\.id).contains($0) } + + let existingIDs = sessions.keys + .filter { + response.value.map(\.id).contains($0) + } + let newSessions = response.value + .filter { + guard let id = $0.id else { return false } + return !sessions.keys.contains(id) + } + .map { s in + BindingBox( + source: .init( + get: { s }, + set: { _ in } + ) + ) + } + + await MainActor.run { + for id in removedSessionIDs { + let t = sessions[id] + sessions[id] = nil + t?.value = nil + } + + for id in existingIDs { + sessions[id]?.value = response.value.first(where: { $0.id == id }) + } + + for session in newSessions { + guard let id = session.value?.id else { continue } + + sessions[id] = session + } + + sessions.sort { x, y in + let xs = x.value.value + let ys = y.value.value + + let isPlaying0 = xs?.nowPlayingItem != nil + let isPlaying1 = ys?.nowPlayingItem != nil + + if isPlaying0 && !isPlaying1 { + return true + } else if !isPlaying0 && isPlaying1 { + return false + } + + if xs?.userName != ys?.userName { + return (xs?.userName ?? "") < (ys?.userName ?? "") + } + + if isPlaying0 && isPlaying1 { + return (xs?.nowPlayingItem?.name ?? "") < (ys?.nowPlayingItem?.name ?? "") + } else { + return (xs?.lastActivityDate ?? Date.now) > (ys?.lastActivityDate ?? Date.now) + } + } + } + } +} diff --git a/Shared/ViewModels/ScheduledTasksViewModel.swift b/Shared/ViewModels/ScheduledTasksViewModel.swift new file mode 100644 index 000000000..cdb2c0490 --- /dev/null +++ b/Shared/ViewModels/ScheduledTasksViewModel.swift @@ -0,0 +1,156 @@ +// +// 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 + +// TODO: do something for errors from restart/shutdown +// - toast? + +final class ScheduledTasksViewModel: ViewModel, Stateful { + + // MARK: - Action + + enum Action: Equatable { + case restartApplication + case shutdownApplication + case getTasks + case refreshTasks + } + + // MARK: - BackgroundState + + enum BackgroundState: Hashable { + case gettingTasks + } + + // MARK: - State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + } + + @Published + final var backgroundStates: OrderedSet = [] + @Published + final var state: State = .initial + @Published + final var tasks: OrderedDictionary = [:] + + private var getTasksCancellable: AnyCancellable? + + func respond(to action: Action) -> State { + switch action { + case .restartApplication: + Task { + try await sendRestartRequest() + } + .store(in: &cancellables) + + return .content + case .shutdownApplication: + Task { + try await sendShutdownRequest() + } + .store(in: &cancellables) + + return .content + case .getTasks: + getTasksCancellable?.cancel() + + getTasksCancellable = Task { + do { + try await getTasks() + + await MainActor.run { + self.state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return state + case .refreshTasks: + tasks.removeAll() + getTasksCancellable?.cancel() + + getTasksCancellable = Task { + do { + await MainActor.run { + self.state = .initial + } + + try await getTasks() + + await MainActor.run { + self.state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .initial + } + } + + // MARK: - Get All Tasks + + // TODO: update tasks like `ActiveSessionsViewModel` + private func getTasks() async throws { + let request = Paths.getTasks(isHidden: false, isEnabled: true) + let response = try await userSession.client.send(request) + + if tasks.isEmpty { + let observers = response.value + .sorted(using: \.category) + .map { ServerTaskObserver(task: $0) } + + let newTasks = OrderedDictionary(grouping: observers, by: { $0.task.category ?? "" }) + + await MainActor.run { + self.tasks = newTasks + } + } + + for runningTask in response.value where runningTask.state == .running { + if let observer = tasks.values + .flatMap(\.self) + .first(where: { $0.task.id == runningTask.id }) + { + await observer.send(.start) + } + } + } + + // MARK: - Restart Application + + private func sendRestartRequest() async throws { + let request = Paths.restartApplication + try await userSession.client.send(request) + } + + // MARK: - Shutdown Application + + private func sendShutdownRequest() async throws { + let request = Paths.shutdownApplication + try await userSession.client.send(request) + } +} diff --git a/Shared/ViewModels/ServerDetailViewModel.swift b/Shared/ViewModels/ServerConnectionViewModel.swift similarity index 98% rename from Shared/ViewModels/ServerDetailViewModel.swift rename to Shared/ViewModels/ServerConnectionViewModel.swift index 6d0591d10..1ec3d7c4e 100644 --- a/Shared/ViewModels/ServerDetailViewModel.swift +++ b/Shared/ViewModels/ServerConnectionViewModel.swift @@ -10,7 +10,7 @@ import CoreStore import Foundation import JellyfinAPI -class EditServerViewModel: ViewModel { +class ServerConnectionViewModel: ViewModel { @Published var server: ServerState diff --git a/Shared/ViewModels/ServerLogsViewModel.swift b/Shared/ViewModels/ServerLogsViewModel.swift new file mode 100644 index 000000000..86ef22f65 --- /dev/null +++ b/Shared/ViewModels/ServerLogsViewModel.swift @@ -0,0 +1,64 @@ +// +// 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 +import OrderedCollections +import SwiftUI + +final class ServerLogsViewModel: ViewModel, Stateful { + + enum Action: Equatable { + case getLogs + } + + enum State: Hashable { + case content + case initial + case error(JellyfinAPIError) + } + + @Published + private(set) var logs: OrderedSet = [] + @Published + final var state: State = .initial + @Published + final var lastAction: Action? + + func respond(to action: Action) -> State { + switch action { + case .getLogs: + cancellables.removeAll() + + Task { + do { + let newLogs = try await getLogs() + + await MainActor.run { + self.logs = newLogs + self.state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .store(in: &cancellables) + + return .initial + } + } + + private func getLogs() async throws -> OrderedSet { + let request = Paths.getServerLogs + let response = try await userSession.client.send(request) + + return OrderedSet(response.value) + } +} diff --git a/Shared/ViewModels/ServerTaskObserver.swift b/Shared/ViewModels/ServerTaskObserver.swift new file mode 100644 index 000000000..efc0ad59e --- /dev/null +++ b/Shared/ViewModels/ServerTaskObserver.swift @@ -0,0 +1,127 @@ +// +// 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 + +// TODO: refactor with socket implementation +// TODO: edit triggers + +final class ServerTaskObserver: ViewModel, Stateful, Identifiable { + + enum Action: Equatable { + case start + case stop + case stopObserving + } + + enum State: Hashable { + case error(JellyfinAPIError) + case initial + case running + } + + @Published + final var state: State = .initial + @Published + private(set) var task: TaskInfo + + private var progressCancellable: AnyCancellable? + private var cancelCancellable: AnyCancellable? + + var id: String? { task.id } + + init(task: TaskInfo) { + self.task = task + } + + func respond(to action: Action) -> State { + switch action { + case .start: + if case .running = state { + return state + } + + progressCancellable = Task { + do { + try await start() + + await MainActor.run { + self.state = .initial + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .running + case .stop: + progressCancellable?.cancel() + cancelCancellable?.cancel() + + cancelCancellable = Task { + do { + try await stop() + + await MainActor.run { + self.state = .initial + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .asAnyCancellable() + + return .initial + case .stopObserving: + progressCancellable?.cancel() + cancelCancellable?.cancel() + + return .initial + } + } + + private func start() async throws { + guard let id = task.id else { return } + + let request = Paths.startTask(taskID: id) + try await userSession.client.send(request) + + try await pollTaskProgress(id: id) + } + + private func pollTaskProgress(id: String) async throws { + while true { + let request = Paths.getTask(taskID: id) + let response = try await userSession.client.send(request) + + await MainActor.run { + self.task = response.value + } + + guard response.value.state == .running || response.value.state == .cancelling else { + break + } + + try await Task.sleep(nanoseconds: 2_000_000_000) + } + } + + private func stop() async throws { + guard let id = task.id else { return } + + let request = Paths.stopTask(taskID: id) + try await userSession.client.send(request) + } +} diff --git a/Swiftfin tvOS/Views/ServerDetailView.swift b/Swiftfin tvOS/Views/ServerDetailView.swift index 84c8ac0c5..52e2d1f9e 100644 --- a/Swiftfin tvOS/Views/ServerDetailView.swift +++ b/Swiftfin tvOS/Views/ServerDetailView.swift @@ -20,10 +20,10 @@ struct EditServerView: View { private var isPresentingConfirmDeletion: Bool = false @StateObject - private var viewModel: EditServerViewModel + private var viewModel: ServerConnectionViewModel init(server: ServerState) { - self._viewModel = StateObject(wrappedValue: EditServerViewModel(server: server)) + self._viewModel = StateObject(wrappedValue: ServerConnectionViewModel(server: server)) } var body: some View { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index ef256cac7..65356413a 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -9,11 +9,17 @@ /* Begin PBXBuildFile section */ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; }; + 4E0A8FFB2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; + 4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.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 */; }; + 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 */; }; + 4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; + 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; 4E2AC4BE2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; }; 4E2AC4BF2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; }; 4E2AC4C22C6C491200DD600D /* AudoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */; }; @@ -29,6 +35,9 @@ 4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; }; 4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; }; 4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; }; + 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */; }; + 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; }; + 4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */; }; 4E71D6892C80910900A0174D /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */; }; 4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; 4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; }; @@ -41,6 +50,10 @@ 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; 4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; }; 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; }; + 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; }; + 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; }; + 4EB1A8CC2C9B1BA200F43898 /* ServerTaskButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */; }; + 4EB1A8CE2C9B2D0800F43898 /* ActiveSessionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */; }; 4EBE06462C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */; }; 4EBE06472C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */; }; 4EBE064D2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */; }; @@ -52,6 +65,12 @@ 4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8512C7FDFA300E2879E /* PlaybackDeviceProfile.swift */; }; 4EC1C8692C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */; }; 4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */; }; + 4EC50D612C934B3A00FC3D0E /* ScheduledTasksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */; }; + 4EC50D622C934B3A00FC3D0E /* ScheduledTasksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */; }; + 4EC6C16B2C92999800FC904B /* TranscodeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */; }; + 4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; + 4ECDAA9F2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; + 4EE141692C8BABDF0045B661 /* ProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ProgressSection.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; }; @@ -579,9 +598,9 @@ E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */; }; E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */; }; E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E172D3B12BACA569007B4647 /* EpisodeContent.swift */; }; - E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; + E173DA5026D048D600CC4EB7 /* EditServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* EditServerView.swift */; }; E173DA5226D04AAF00CC4EB7 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* Color.swift */; }; - E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; + E173DA5426D050F500CC4EB7 /* ServerConnectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerConnectionViewModel.swift */; }; E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */; }; E174121029AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */; }; E175AFF3299AC117004DCF52 /* DebugSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */; }; @@ -707,7 +726,7 @@ E193D549271941CC00900D82 /* UserSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D548271941CC00900D82 /* UserSignInView.swift */; }; E193D54B271941D300900D82 /* SelectServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D54A271941D300900D82 /* SelectServerView.swift */; }; E193D5502719430400900D82 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D54F2719430400900D82 /* ServerDetailView.swift */; }; - E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; + E193D5512719432400900D82 /* ServerConnectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerConnectionViewModel.swift */; }; E193D553271943D500900D82 /* tvOSMainTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E193D552271943D500900D82 /* tvOSMainTabCoordinator.swift */; }; E19D41A72BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */; }; E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */; }; @@ -765,6 +784,8 @@ E1B490452967E26300D3EDCE /* PersistentLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */; }; E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490462967E2E500D3EDCE /* CoreStore.swift */; }; E1B490482967E2E500D3EDCE /* CoreStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B490462967E2E500D3EDCE /* CoreStore.swift */; }; + E1B4E4372CA7795200DC49DE /* OrderedDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */; }; + E1B4E4382CA7795200DC49DE /* OrderedDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */; }; E1B5784128F8AFCB00D42911 /* WrappedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* WrappedView.swift */; }; E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5784028F8AFCB00D42911 /* WrappedView.swift */; }; E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B5861129E32EEF00E45D6E /* Sequence.swift */; }; @@ -933,6 +954,15 @@ E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */; }; E1EBCB42278BD174009FE6E9 /* TruncatedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */; }; E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */; }; + E1ED7FD62CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */; }; + E1ED7FD82CA8AF7400ACB6E3 /* ServerTaskObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */; }; + E1ED7FD92CA8AF7400ACB6E3 /* ServerTaskObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */; }; + E1ED7FDB2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */; }; + E1ED7FDC2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */; }; + E1ED7FDE2CAA641F00ACB6E3 /* ListTitleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FDD2CAA641F00ACB6E3 /* ListTitleSection.swift */; }; + E1ED7FE02CAA685900ACB6E3 /* ServerLogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */; }; + E1ED7FE22CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FE12CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift */; }; + E1ED7FE32CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED7FE12CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift */; }; E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED91142B95897500802036 /* LatestInLibraryViewModel.swift */; }; E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED91142B95897500802036 /* LatestInLibraryViewModel.swift */; }; E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ED91172B95993300802036 /* TitledLibraryParent.swift */; }; @@ -940,6 +970,10 @@ E1EF473A289A0F610034046B /* TruncatedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */; }; E1EF4C412911B783008CC695 /* StreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EF4C402911B783008CC695 /* StreamType.swift */; }; E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; }; + E1F5CF052CB09EA000607465 /* CurrentDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F5CF042CB09EA000607465 /* CurrentDate.swift */; }; + E1F5CF062CB09EA000607465 /* CurrentDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F5CF042CB09EA000607465 /* CurrentDate.swift */; }; + E1F5CF082CB0A04500607465 /* Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F5CF072CB0A04500607465 /* Text.swift */; }; + E1F5CF092CB0A04500607465 /* Text.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F5CF072CB0A04500607465 /* Text.swift */; }; E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */; }; E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */; }; E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E1FAD1C52A0375BA007F5521 /* UDPBroadcast */; }; @@ -979,10 +1013,14 @@ /* Begin PBXFileReference section */ 091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = ""; }; + 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = ""; }; 4E16FD502C0183DB00110147 /* LetterPickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerButton.swift; sourceTree = ""; }; 4E16FD522C01840C00110147 /* LetterPickerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerBar.swift; sourceTree = ""; }; 4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerOrientation.swift; sourceTree = ""; }; + 4E182C9B2C94993200FBEFD5 /* ScheduledTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTasksView.swift; sourceTree = ""; }; + 4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTaskButton.swift; sourceTree = ""; }; 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = ""; }; + 4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = ""; }; 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileAction.swift; sourceTree = ""; }; 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudoCodec.swift; sourceTree = ""; }; 4E2AC4C42C6C492700DD600D /* MediaContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainer.swift; sourceTree = ""; }; @@ -993,6 +1031,9 @@ 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; + 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDashboardView.swift; sourceTree = ""; }; + 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = ""; }; + 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionDetailView.swift; sourceTree = ""; }; 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; @@ -1001,6 +1042,10 @@ 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = ""; }; 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; + 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = ""; }; + 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = ""; }; + 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskButton.swift; sourceTree = ""; }; + 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionRow.swift; sourceTree = ""; }; 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCompatibility.swift; sourceTree = ""; }; 4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = ""; }; 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = ""; }; @@ -1008,6 +1053,10 @@ 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileCoordinator.swift; sourceTree = ""; }; 4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = ""; }; 4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; + 4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTasksViewModel.swift; sourceTree = ""; }; + 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = ""; }; + 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = ""; }; + 4EE141682C8BABDF0045B661 /* ProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressSection.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -1351,9 +1400,9 @@ E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBlurHashes.swift; sourceTree = ""; }; E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemViewModel.swift; sourceTree = ""; }; E172D3B12BACA569007B4647 /* EpisodeContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeContent.swift; sourceTree = ""; }; - E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; + E173DA4F26D048D600CC4EB7 /* EditServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerView.swift; sourceTree = ""; }; E173DA5126D04AAF00CC4EB7 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; - E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; + E173DA5326D050F500CC4EB7 /* ServerConnectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnectionViewModel.swift; sourceTree = ""; }; E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinatable.swift; sourceTree = ""; }; E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSettingsView.swift; sourceTree = ""; }; E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionMenu.swift; sourceTree = ""; }; @@ -1478,6 +1527,7 @@ E1B33ED028EB860A0073B0FD /* LargePlaybackButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargePlaybackButtons.swift; sourceTree = ""; }; E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentLogHandler.swift; sourceTree = ""; }; E1B490462967E2E500D3EDCE /* CoreStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreStore.swift; sourceTree = ""; }; + E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedDictionary.swift; sourceTree = ""; }; E1B5784028F8AFCB00D42911 /* WrappedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedView.swift; sourceTree = ""; }; E1B5861129E32EEF00E45D6E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetScrollView.swift; sourceTree = ""; }; @@ -1605,10 +1655,18 @@ E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerManager.swift; sourceTree = ""; }; E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TruncatedText.swift; sourceTree = ""; }; E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewView.swift; sourceTree = ""; }; + E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditScheduledTaskView.swift; sourceTree = ""; }; + E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskObserver.swift; sourceTree = ""; }; + E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStateInfo.swift; sourceTree = ""; }; + E1ED7FDD2CAA641F00ACB6E3 /* ListTitleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTitleSection.swift; sourceTree = ""; }; + E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerLogsView.swift; sourceTree = ""; }; + E1ED7FE12CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerLogsViewModel.swift; sourceTree = ""; }; E1ED91142B95897500802036 /* LatestInLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestInLibraryViewModel.swift; sourceTree = ""; }; E1ED91172B95993300802036 /* TitledLibraryParent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitledLibraryParent.swift; sourceTree = ""; }; E1EF4C402911B783008CC695 /* StreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamType.swift; sourceTree = ""; }; E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = ""; }; + E1F5CF042CB09EA000607465 /* CurrentDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentDate.swift; sourceTree = ""; }; + E1F5CF072CB0A04500607465 /* Text.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Text.swift; sourceTree = ""; }; E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemView.swift; sourceTree = ""; }; E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemContentView.swift; sourceTree = ""; }; E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; @@ -1755,6 +1813,24 @@ path = Components; sourceTree = ""; }; + 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */ = { + isa = PBXGroup; + children = ( + 4E182C9D2C94A01600FBEFD5 /* Components */, + 4E182C9B2C94993200FBEFD5 /* ScheduledTasksView.swift */, + ); + path = ScheduledTasksView; + sourceTree = ""; + }; + 4E182C9D2C94A01600FBEFD5 /* Components */ = { + isa = PBXGroup; + children = ( + 4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */, + 4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */, + ); + path = Components; + sourceTree = ""; + }; 4E2AC4C02C6C48EB00DD600D /* MediaComponents */ = { isa = PBXGroup; children = ( @@ -1775,6 +1851,28 @@ path = PlaybackBitrate; sourceTree = ""; }; + 4E63B9F52C8A5BEF00C25378 /* UserDashboardView */ = { + isa = PBXGroup; + children = ( + 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */, + 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */, + E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */, + 4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */, + E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */, + 4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */, + ); + path = UserDashboardView; + sourceTree = ""; + }; + 4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */ = { + isa = PBXGroup; + children = ( + 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */, + 4EB1A8D32C9B91A200F43898 /* Components */, + ); + path = ActiveSessionDetailView; + sourceTree = ""; + }; 4E9A24E32C82B4700023DA83 /* CustomDeviceProfileSettingsView */ = { isa = PBXGroup; children = ( @@ -1793,6 +1891,33 @@ path = Components; sourceTree = ""; }; + 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */ = { + isa = PBXGroup; + children = ( + 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */, + 4EB1A8D02C9B2FB600F43898 /* Components */, + ); + path = ActiveSessionsView; + sourceTree = ""; + }; + 4EB1A8D02C9B2FB600F43898 /* Components */ = { + isa = PBXGroup; + children = ( + 4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */, + 4EE141682C8BABDF0045B661 /* ProgressSection.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4EB1A8D32C9B91A200F43898 /* Components */ = { + isa = PBXGroup; + children = ( + 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */, + 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */, + ); + path = Components; + sourceTree = ""; + }; 4EC1C86A2C80900B00E2879E /* CustomDeviceProfileSettingsView */ = { isa = PBXGroup; children = ( @@ -1828,6 +1953,7 @@ 532175392671BCED005491E6 /* ViewModels */ = { isa = PBXGroup; children = ( + 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */, E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */, @@ -1840,10 +1966,13 @@ E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */, 6334175C287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift */, E1BCDB4E2BE1F491009F6744 /* ResetUserPasswordViewModel.swift */, + 4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */, 62E632DB267D2E130063E547 /* SearchViewModel.swift */, E13DD3F82717E961009D4DAF /* SelectUserViewModel.swift */, E19D41AD2BF288320082B8B2 /* ServerCheckViewModel.swift */, - E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, + E173DA5326D050F500CC4EB7 /* ServerConnectionViewModel.swift */, + E1ED7FE12CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift */, + E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */, 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */, E19D41A62BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift */, E14EA1682BF7330A00DE757A /* UserProfileImageViewModel.swift */, @@ -1941,6 +2070,7 @@ E129429728F4785200796AC6 /* CaseIterablePicker.swift */, E10231432BCF8A51009D71FC /* ChannelProgram.swift */, E1CB756E2C80E66700217C76 /* CommaStringBuilder.swift */, + E1F5CF042CB09EA000607465 /* CurrentDate.swift */, 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */, E17FB55128C119D400311DFE /* Displayable.swift */, E1579EA62B97DC1500A31CA1 /* Eventful.swift */, @@ -2234,6 +2364,7 @@ 4E16FD4E2C0183B500110147 /* LetterPickerBar */, E1A8FDEB2C0574A800D0A51C /* ListRow.swift */, E1AEFA362BE317E200CFAFD8 /* ListRowButton.swift */, + E1ED7FDD2CAA641F00ACB6E3 /* ListTitleSection.swift */, E1FE69AF28C2DA4A0021BC93 /* NavigationBarFilterDrawer */, E1DE84132B9531C1008CCE21 /* OrderedSectionSelectorView.swift */, E18E01A5288746AF0022598C /* PillHStack.swift */, @@ -2275,11 +2406,13 @@ E1AD105226D96D5F003E4A08 /* JellyfinAPI */, E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */, E150C0B82BFD44E900944FFA /* Nuke */, + E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */, E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, E1B5861129E32EEF00E45D6E /* Sequence.swift */, E145EB442BE0AD4E003BF6F3 /* Set.swift */, 621338922660107500A81A2A /* String.swift */, E1DD55362B6EE533007501C0 /* Task.swift */, + E1F5CF072CB0A04500607465 /* Text.swift */, E1A2C153279A7D5A005EC829 /* UIApplication.swift */, E1401CB029386C9200E8B599 /* UIColor.swift */, E13DD3C727164B1E009D4DAF /* UIDevice.swift */, @@ -2797,6 +2930,7 @@ 5338F74D263B61370014BF09 /* ConnectToServerView.swift */, E17AC96C2954E9CA003D2BC2 /* DownloadListView.swift */, E13332922953BA9400EE76AB /* DownloadTaskView */, + E173DA4F26D048D600CC4EB7 /* EditServerView.swift */, E113133128BDC72000930F75 /* FilterView.swift */, 62C83B07288C6A630004ED0C /* FontPickerView.swift */, E168BD07289A4162001A6922 /* HomeView */, @@ -2811,7 +2945,6 @@ 53EE24E5265060780068F029 /* SearchView.swift */, E10B1EAF2BD9769500A92EAF /* SelectUserView */, E19D41AB2BF288110082B8B2 /* ServerCheckView.swift */, - E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */, E1E5D54A2783E26100692DFE /* SettingsView */, E1171A1A28A2215800FA1AF5 /* UserSignInView */, E193D5452719418B00900D82 /* VideoPlayer */, @@ -3351,8 +3484,10 @@ E1D37F5B2B9CF02600343D2B /* BaseItemDto */, E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */, E1002B632793CEE700E47059 /* ChapterInfo.swift */, + E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */, 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */, + 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */, E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */, E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */, E1D842902933F87500D1041A /* ItemFields.swift */, @@ -3363,8 +3498,10 @@ E122A9122788EAAD0060FA63 /* MediaStream.swift */, E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */, E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */, + 4E2182E42CAF67EF0094806B /* PlayMethod.swift */, E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */, E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */, + 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */, E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */, E18CE0B128A229E70092E7F1 /* UserDto.swift */, ); @@ -3626,12 +3763,13 @@ 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */, E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */, E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */, - E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */, E16AF11B292C98A7001422A8 /* GestureSettingsView.swift */, + E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */, E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */, 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */, - E1545BD62BDC559500D9578F /* UserProfileSettingsView */, E1BE1CEB2BDB68BC008176A9 /* SettingsView */, + 4E63B9F52C8A5BEF00C25378 /* UserDashboardView */, + E1545BD62BDC559500D9578F /* UserProfileSettingsView */, E1BDF2E7295148F400CC0294 /* VideoPlayerSettingsView */, ); path = SettingsView; @@ -4127,6 +4265,7 @@ E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */, E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, 4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */, + 4EC50D622C934B3A00FC3D0E /* ScheduledTasksViewModel.swift in Sources */, E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */, E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */, E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */, @@ -4136,12 +4275,14 @@ E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */, E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */, + E1ED7FDB2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */, E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */, E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */, E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, + 4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, C46DD8D32A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */, E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, @@ -4168,6 +4309,7 @@ E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */, E1803EA22BFBD6CF0039F90E /* Hashable.swift in Sources */, E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */, + E1ED7FD82CA8AF7400ACB6E3 /* ServerTaskObserver.swift in Sources */, E1575E93293E7B1E001665B1 /* Double.swift in Sources */, E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */, E11895AA289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, @@ -4191,6 +4333,7 @@ E1575E87293E7A00001665B1 /* InvertedDarkAppIcon.swift in Sources */, E10432F72BE4426F006FF9DD /* FormatStyle.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, + 4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, E150C0BB2BFD44F500944FFA /* ImagePipeline.swift in Sources */, @@ -4206,6 +4349,7 @@ E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */, E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */, E1CAF65E2BA345830087D991 /* MediaType.swift in Sources */, + E1F5CF062CB09EA000607465 /* CurrentDate.swift in Sources */, E12376B02A33D6AE001F5B44 /* AboutViewCard.swift in Sources */, E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */, E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */, @@ -4215,6 +4359,7 @@ E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */, E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */, E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */, + 4ECDAA9F2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */, E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */, E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, E17DC74B2BE740D900B42379 /* StoredValues+Server.swift in Sources */, @@ -4222,6 +4367,7 @@ E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, E13316FF2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */, E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, + E1F5CF082CB0A04500607465 /* Text.swift in Sources */, E43918672AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */, E154966F296CA2EF00C4EF88 /* LogManager.swift in Sources */, E1575E75293E77B5001665B1 /* LibraryDisplayType.swift in Sources */, @@ -4251,6 +4397,7 @@ E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, E14EDEC92B8FB65F000F00A4 /* ItemFilterType.swift in Sources */, E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */, + E1B4E4372CA7795200DC49DE /* OrderedDictionary.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */, 62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */, @@ -4261,6 +4408,7 @@ E164A7F72BE4816500A54B18 /* SelectUserServerSelection.swift in Sources */, E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */, E1153DCD2BBB633B00424D36 /* FastSVGView.swift in Sources */, + E1ED7FE22CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */, E1CB75762C80EAFA00217C76 /* ArrayBuilder.swift in Sources */, E102315B2BCF8AF8009D71FC /* ProgramProgressOverlay.swift in Sources */, E1E6C45129B104850064123F /* Button.swift in Sources */, @@ -4354,7 +4502,7 @@ E1575E65293E77B5001665B1 /* VideoPlayerJumpLength.swift in Sources */, E169C7B8296D2E8200AE25F9 /* SpecialFeaturesHStack.swift in Sources */, E1153D962BBA3E2F00424D36 /* EpisodeHStack.swift in Sources */, - E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, + E193D5512719432400900D82 /* ServerConnectionViewModel.swift in Sources */, E1B5861329E32EEF00E45D6E /* Sequence.swift in Sources */, C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, @@ -4439,6 +4587,7 @@ 621338932660107500A81A2A /* String.swift in Sources */, E17AC96F2954EE4B003D2BC2 /* DownloadListViewModel.swift in Sources */, BD39577C2C113FAA0078CEF8 /* TimestampSection.swift in Sources */, + 4EC6C16B2C92999800FC904B /* TranscodeSection.swift in Sources */, 62C83B08288C6A630004ED0C /* FontPickerView.swift in Sources */, E122A9132788EAAD0060FA63 /* MediaStream.swift in Sources */, E1E9017F28DAB15F001B1594 /* BarActionButtons.swift in Sources */, @@ -4449,12 +4598,14 @@ 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* MediaCoordinator.swift in Sources */, + E1F5CF052CB09EA000607465 /* CurrentDate.swift in Sources */, E13316FE2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */, 62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */, E1A1528A28FD22F600600579 /* TextPairView.swift in Sources */, E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */, E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */, E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, + 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */, E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */, E1AEFA372BE317E200CFAFD8 /* ListRowButton.swift in Sources */, E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, @@ -4472,6 +4623,7 @@ E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */, E154966E296CA2EF00C4EF88 /* LogManager.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, + E1B4E4382CA7795200DC49DE /* OrderedDictionary.swift in Sources */, E18E01E8288747230022598C /* SeriesItemContentView.swift in Sources */, E16AA60828A364A6009A983C /* PosterButton.swift in Sources */, E1E1644128BB301900323B0A /* Array.swift in Sources */, @@ -4498,6 +4650,7 @@ E1A3E4CD2BB7D8C8005C59F8 /* Label-iOS.swift in Sources */, E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */, + E1ED7FD92CA8AF7400ACB6E3 /* ServerTaskObserver.swift in Sources */, E17AC96A2954D00E003D2BC2 /* URLResponse.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, @@ -4531,7 +4684,7 @@ E17FB55228C119D400311DFE /* Displayable.swift in Sources */, E113132B28BDB4B500930F75 /* NavigationBarDrawerView.swift in Sources */, E164A7F62BE4814700A54B18 /* SelectUserServerSelection.swift in Sources */, - E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, + E173DA5426D050F500CC4EB7 /* ServerConnectionViewModel.swift in Sources */, E1EA09882BEE9CF3004CDE76 /* UserLocalSecurityView.swift in Sources */, E1559A76294D960C00C1FFBC /* MainOverlay.swift in Sources */, E14EDECC2B8FB709000F00A4 /* ItemYear.swift in Sources */, @@ -4567,6 +4720,7 @@ E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */, E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */, E1E6C45029B104840064123F /* Button.swift in Sources */, + 4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */, E1153DCC2BBB633B00424D36 /* FastSVGView.swift in Sources */, E10432F62BE4426F006FF9DD /* FormatStyle.swift in Sources */, E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */, @@ -4584,10 +4738,13 @@ E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, E1BAFE102BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift in Sources */, + E1ED7FDE2CAA641F00ACB6E3 /* ListTitleSection.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */, E1A8FDEC2C0574A800D0A51C /* ListRow.swift in Sources */, + 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */, E1DD55372B6EE533007501C0 /* Task.swift in Sources */, + E1ED7FE02CAA685900ACB6E3 /* ServerLogsView.swift in Sources */, E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */, E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, @@ -4608,6 +4765,7 @@ E18CE0B228A229E70092E7F1 /* UserDto.swift in Sources */, E18E01F0288747230022598C /* AttributeHStack.swift in Sources */, 6334175B287DDFB9000603CE /* QuickConnectAuthorizeView.swift in Sources */, + 4E0A8FFB2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */, E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */, E168BD10289A4162001A6922 /* HomeView.swift in Sources */, E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */, @@ -4628,6 +4786,7 @@ E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, + 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */, E145EB482BE0C136003BF6F3 /* ScrollIfLargerThanContainerModifier.swift in Sources */, E1CB757C2C80F00D00217C76 /* TranscodingProfile.swift in Sources */, E1CAF65F2BA345830087D991 /* MediaViewModel.swift in Sources */, @@ -4652,6 +4811,7 @@ E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */, E12CC1AE28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */, E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, + 4EE141692C8BABDF0045B661 /* ProgressSection.swift in Sources */, E1A1528528FD191A00600579 /* TextPair.swift in Sources */, 6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */, E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, @@ -4695,6 +4855,7 @@ E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */, E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E14EA1602BF6FF8900DE757A /* UserProfileImagePicker.swift in Sources */, + 4E182C9C2C94993200FBEFD5 /* ScheduledTasksView.swift in Sources */, E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */, E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */, E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */, @@ -4735,6 +4896,7 @@ E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E12376AE2A33D680001F5B44 /* AboutView+Card.swift in Sources */, E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */, + 4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */, E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */, E1D8428F2933F2D900D1041A /* MediaSourceInfo.swift in Sources */, E1BDF2EC2952290200CC0294 /* AspectFillActionButton.swift in Sources */, @@ -4745,6 +4907,7 @@ E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */, E14EA1692BF7330A00DE757A /* UserProfileImageViewModel.swift in Sources */, E18ACA952A15A3E100BB4F35 /* (null) in Sources */, + 4EB1A8CE2C9B2D0800F43898 /* ActiveSessionRow.swift in Sources */, E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */, E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */, E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */, @@ -4756,13 +4919,14 @@ 4E16FD512C0183DB00110147 /* LetterPickerButton.swift in Sources */, E19D41AE2BF288320082B8B2 /* ServerCheckViewModel.swift in Sources */, E1BDF2F329524C3B00CC0294 /* ChaptersActionButton.swift in Sources */, - E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */, + E173DA5026D048D600CC4EB7 /* EditServerView.swift in Sources */, E1BE1CF02BDB6C97008176A9 /* UserProfileSettingsView.swift in Sources */, E1DC7ACA2C63337C00AEE368 /* iOS15View.swift in Sources */, E1CFE28028FA606800B7D34C /* ChapterTrack.swift in Sources */, E1401CA22938122C00E8B599 /* AppIcons.swift in Sources */, E1BDF2FB2952502300CC0294 /* SubtitleActionButton.swift in Sources */, E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */, + 4EC50D612C934B3A00FC3D0E /* ScheduledTasksViewModel.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, E150C0BD2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */, E190704D2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */, @@ -4771,12 +4935,14 @@ E190704F2C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */, E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */, + E1ED7FDC2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */, E149CCAD2BE6ECC8008B9331 /* Storable.swift in Sources */, E1CB75792C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */, E18ACA8F2A15A2CF00BB4F35 /* (null) in Sources */, E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */, E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */, E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */, + 4EB1A8CC2C9B1BA200F43898 /* ServerTaskButton.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, E10B1EB62BD98C6600A92EAF /* AddUserRow.swift in Sources */, E1CB75802C80F28F00217C76 /* SubtitleProfile.swift in Sources */, @@ -4793,6 +4959,7 @@ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, E1721FAE28FB801C00762992 /* SmallPlaybackButtons.swift in Sources */, E1A7B1662B9ADAD300152546 /* ItemTypeLibraryViewModel.swift in Sources */, + E1ED7FD62CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift in Sources */, E1545BD82BDC55C300D9578F /* ResetUserPasswordView.swift in Sources */, E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */, E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */, @@ -4803,6 +4970,7 @@ C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, + 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */, 535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */, E1D27EE72BBC955F00152D16 /* UnmaskSecureField.swift in Sources */, E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */, @@ -4810,6 +4978,8 @@ E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E18E01F1288747230022598C /* PlayButton.swift in Sources */, E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */, + E1F5CF092CB0A04500607465 /* Text.swift in Sources */, + 4E182C9F2C94A1E000FBEFD5 /* ScheduledTaskButton.swift in Sources */, E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */, E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */, @@ -4830,6 +5000,7 @@ E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, E1D37F482B9C648E00343D2B /* MaxHeightText.swift in Sources */, BD3957752C112A330078CEF8 /* ButtonSection.swift in Sources */, + E1ED7FE32CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */, E1ED91182B95993300802036 /* TitledLibraryParent.swift in Sources */, E13DD3F92717E961009D4DAF /* SelectUserViewModel.swift in Sources */, E1194F502BEB1E3000888DB6 /* StoredValues+Temp.swift in Sources */, @@ -4853,6 +5024,7 @@ E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */, E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */, E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */, + 4E63B9FA2C8A5BEF00C25378 /* UserDashboardView.swift in Sources */, E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */, 4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */, E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */, diff --git a/Swiftfin/Components/ChevronButton.swift b/Swiftfin/Components/ChevronButton.swift index 88d1c33e9..6cc1dba80 100644 --- a/Swiftfin/Components/ChevronButton.swift +++ b/Swiftfin/Components/ChevronButton.swift @@ -10,6 +10,7 @@ import SwiftUI struct ChevronButton: View { + private let isExternal: Bool private let title: String private let subtitle: String? private var leadingView: () -> any View @@ -34,7 +35,7 @@ struct ChevronButton: View { .foregroundColor(.secondary) } - Image(systemName: "chevron.right") + Image(systemName: isExternal ? "arrow.up.forward" : "chevron.right") .font(.body.weight(.regular)) .foregroundColor(.secondary) } @@ -44,8 +45,9 @@ struct ChevronButton: View { extension ChevronButton { - init(_ title: String, subtitle: String? = nil) { + init(_ title: String, subtitle: String? = nil, external: Bool = false) { self.init( + isExternal: external, title: title, subtitle: subtitle, leadingView: { EmptyView() }, diff --git a/Swiftfin/Components/CircularProgressView.swift b/Swiftfin/Components/CircularProgressView.swift index 8d5de2811..6099f821a 100644 --- a/Swiftfin/Components/CircularProgressView.swift +++ b/Swiftfin/Components/CircularProgressView.swift @@ -6,35 +6,80 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // +import Defaults import SwiftUI -struct CircularProgressView: View { +// SwiftUI gauge style not available on iOS 15 + +struct GaugeProgressStyle: ProgressViewStyle { + + @Default(.accentColor) + private var accentColor @State - private var lineWidth: CGFloat = 1 + private var contentSize: CGSize = .zero - let progress: Double + private var lineWidthRatio: CGFloat + private var systemImage: String? - var body: some View { + func makeBody(configuration: Configuration) -> some View { ZStack { + + if let systemImage { + Image(systemName: systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: contentSize.width / 2.5, maxHeight: contentSize.height / 2.5) + .foregroundStyle(.secondary) + .padding(6) + } + Circle() .stroke( - Color.green.opacity(0.5), - lineWidth: lineWidth + Color.gray.opacity(0.2), + lineWidth: contentSize.width / lineWidthRatio ) + Circle() - .trim(from: 0, to: progress) + .trim(from: 0, to: configuration.fractionCompleted ?? 0) .stroke( - Color.green, + accentColor, style: StrokeStyle( - lineWidth: lineWidth, + lineWidth: contentSize.width / lineWidthRatio, lineCap: .round ) ) .rotationEffect(.degrees(-90)) } - .onSizeChanged { size in - lineWidth = size.width / 3.5 - } + .animation(.linear(duration: 0.1), value: configuration.fractionCompleted) + .trackingSize($contentSize) + } +} + +extension GaugeProgressStyle { + + init() { + self.init( + lineWidthRatio: 5, + systemImage: nil + ) + } + + init(systemImage: String) { + self.init( + lineWidthRatio: 8, + systemImage: systemImage + ) + } +} + +extension ProgressViewStyle where Self == GaugeProgressStyle { + + static var gauge: GaugeProgressStyle { + GaugeProgressStyle() + } + + static func gauge(systemImage: String) -> GaugeProgressStyle { + GaugeProgressStyle(systemImage: systemImage) } } diff --git a/Swiftfin/Components/ListTitleSection.swift b/Swiftfin/Components/ListTitleSection.swift new file mode 100644 index 000000000..37476946f --- /dev/null +++ b/Swiftfin/Components/ListTitleSection.swift @@ -0,0 +1,66 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// TODO: image + +struct ListTitleSection: View { + + private let title: String + private let description: String? + private let onLearnMore: (() -> Void)? + + var body: some View { + Section { + VStack(alignment: .center, spacing: 10) { + + Text(title) + .font(.title3) + .fontWeight(.semibold) + + if let description { + Text(description) + .multilineTextAlignment(.center) + } + + if let onLearnMore { + Button("Learn More\u{2026}", action: onLearnMore) + } + } + .font(.subheadline) + .frame(maxWidth: .infinity) + } + } +} + +extension ListTitleSection { + + init( + _ title: String, + description: String? = nil + ) { + self.init( + title: title, + description: description, + onLearnMore: nil + ) + } + + init( + _ title: String, + description: String? = nil, + onLearnMore: @escaping () -> Void + ) { + self.init( + title: title, + description: description, + onLearnMore: onLearnMore + ) + } +} diff --git a/Swiftfin/Extensions/Label-iOS.swift b/Swiftfin/Extensions/Label-iOS.swift index eae5a6a69..89251c698 100644 --- a/Swiftfin/Extensions/Label-iOS.swift +++ b/Swiftfin/Extensions/Label-iOS.swift @@ -42,6 +42,8 @@ struct EpisodeSelectorLabelStyle: LabelStyle { // MARK: SectionFooterWithImageLabelStyle +// TODO: rename as not only used in section footers + extension LabelStyle where Self == SectionFooterWithImageLabelStyle { static func sectionFooterWithImage(imageStyle: ImageStyle) -> SectionFooterWithImageLabelStyle { diff --git a/Swiftfin/Extensions/View/View-iOS.swift b/Swiftfin/Extensions/View/View-iOS.swift index e54aeeb7d..f4440f542 100644 --- a/Swiftfin/Extensions/View/View-iOS.swift +++ b/Swiftfin/Extensions/View/View-iOS.swift @@ -8,6 +8,7 @@ import Defaults import SwiftUI +import SwiftUIIntrospect extension View { @@ -77,4 +78,22 @@ extension View { ) ) } + + @ViewBuilder + func listRowCornerRadius(_ radius: CGFloat) -> some View { + if #unavailable(iOS 16) { + introspect(.listCell, on: .iOS(.v15)) { cell in + cell.layer.cornerRadius = radius + } + } else { + introspect( + .listCell, + on: .iOS(.v16), + .iOS(.v17), + .iOS(.v18) + ) { cell in + cell.layer.cornerRadius = radius + } + } + } } diff --git a/Swiftfin/Views/AboutAppView.swift b/Swiftfin/Views/AboutAppView.swift index 75ce5ca2a..0626e319d 100644 --- a/Swiftfin/Views/AboutAppView.swift +++ b/Swiftfin/Views/AboutAppView.swift @@ -38,7 +38,7 @@ struct AboutAppView: View { trailing: "\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))" ) - ChevronButton(L10n.sourceCode) + ChevronButton(L10n.sourceCode, external: true) .leadingView { Image(.logoGithub) .resizable() @@ -50,7 +50,7 @@ struct AboutAppView: View { UIApplication.shared.open(.swiftfinGithub) } - ChevronButton(L10n.bugsAndFeatures) + ChevronButton(L10n.bugsAndFeatures, external: true) .leadingView { Image(systemName: "plus.circle.fill") .resizable() @@ -64,7 +64,7 @@ struct AboutAppView: View { UIApplication.shared.open(.swiftfinGithubIssues) } - ChevronButton(L10n.settings) + ChevronButton(L10n.settings, external: true) .leadingView { Image(systemName: "gearshape.fill") .resizable() diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift index 3f605e7e7..207b6b14d 100644 --- a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift +++ b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift @@ -56,9 +56,9 @@ extension DownloadTaskView { .frame(height: 50) case let .downloading(progress): HStack { - CircularProgressView(progress: progress) - .buttonStyle(.plain) - .frame(width: 30, height: 30) +// CircularProgressView(progress: progress) +// .buttonStyle(.plain) +// .frame(width: 30, height: 30) Text("\(Int(progress * 100))%") .foregroundColor(.secondary) diff --git a/Swiftfin/Views/ServerDetailView.swift b/Swiftfin/Views/EditServerView.swift similarity index 93% rename from Swiftfin/Views/ServerDetailView.swift rename to Swiftfin/Views/EditServerView.swift index c04551cc8..b6d35120e 100644 --- a/Swiftfin/Views/ServerDetailView.swift +++ b/Swiftfin/Views/EditServerView.swift @@ -29,10 +29,10 @@ struct EditServerView: View { private var isPresentingConfirmDeletion: Bool = false @StateObject - private var viewModel: EditServerViewModel + private var viewModel: ServerConnectionViewModel init(server: ServerState) { - self._viewModel = StateObject(wrappedValue: EditServerViewModel(server: server)) + self._viewModel = StateObject(wrappedValue: ServerConnectionViewModel(server: server)) self._currentServerURL = State(initialValue: server.currentURL) } diff --git a/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift b/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift index 5deb06099..fecc6d3d3 100644 --- a/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift +++ b/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift @@ -30,8 +30,9 @@ struct DownloadTaskButton: View { case .complete: Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) - case let .downloading(progress): - CircularProgressView(progress: progress) + case .downloading: + EmptyView() +// CircularProgressView(progress: progress) case .error: Image(systemName: "exclamationmark.circle.fill") .foregroundColor(.red) diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift index e24a37828..8042568b9 100644 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift @@ -14,9 +14,6 @@ extension PagingLibraryView { struct LibraryRow: View { - @State - private var contentWidth: CGFloat = 0 - private let item: Element private var action: () -> Void private let posterType: PosterDisplayType diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift index 9d68c0427..514710df1 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -32,7 +32,7 @@ import SwiftUI Note: For `rememberLayout` and `rememberSort`, there are quirks for observing changes while a library is open and the setting has been changed. For simplicity, do not enforce observing - changes and doing proper updates since there is complexitry with what "actual" settings + changes and doing proper updates since there is complexity with what "actual" settings should be applied. */ diff --git a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift index d8be7ef06..fc4a29ae8 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/Components/UserProfileRow.swift @@ -7,6 +7,7 @@ // import Factory +import JellyfinAPI import SwiftUI extension SettingsView { @@ -16,12 +17,13 @@ extension SettingsView { @Injected(\.currentUserSession) private var userSession: UserSession! - let action: () -> Void + private let user: UserDto + private let action: (() -> Void)? @ViewBuilder private var imageView: some View { RedrawOnNotificationView(.didChangeUserProfileImage) { - ImageView(userSession.user.profileImageSource(client: userSession.client, maxWidth: 120)) + ImageView(user.profileImageSource(client: userSession.client, maxWidth: 120)) .pipeline(.Swiftfin.branding) .placeholder { _ in SystemImageContentView(systemName: "person.fill", ratio: 0.5) @@ -34,6 +36,7 @@ extension SettingsView { var body: some View { Button { + guard let action else { return } action() } label: { HStack { @@ -47,18 +50,37 @@ extension SettingsView { .clipShape(.circle) .frame(width: 50, height: 50) - Text(userSession.user.username) + Text(user.name ?? .emptyDash) .fontWeight(.semibold) .foregroundStyle(.primary) Spacer() - Image(systemName: "chevron.right") - .font(.body.weight(.regular)) - .foregroundColor(.secondary) + if action != nil { + Image(systemName: "chevron.right") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } } } .foregroundStyle(.primary, .secondary) } } } + +extension SettingsView.UserProfileRow { + + init(user: UserDto) { + self.init( + user: user, + action: nil + ) + } + + init(user: UserDto, perform action: @escaping () -> Void) { + self.init( + user: user, + action: action + ) + } +} diff --git a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift index 84189d261..22fa795c4 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift @@ -6,9 +6,8 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CoreData import Defaults -import Factory +import JellyfinAPI import Stinsen import SwiftUI @@ -16,7 +15,6 @@ struct SettingsView: View { @Default(.userAccentColor) private var accentColor - @Default(.userAppearance) private var appearance @Default(.VideoPlayer.videoPlayerType) @@ -33,17 +31,23 @@ struct SettingsView: View { Section { - UserProfileRow { + UserProfileRow(user: viewModel.userSession.user.data) { router.route(to: \.userProfile, viewModel) } - // TODO: admin users go to dashboard instead ChevronButton( L10n.server, subtitle: viewModel.userSession.server.name ) .onSelect { - router.route(to: \.serverDetail, viewModel.userSession.server) + router.route(to: \.serverConnection, viewModel.userSession.server) + } + + if viewModel.userSession.user.isAdministrator { + ChevronButton(L10n.dashboard) + .onSelect { + router.route(to: \.userDashboard) + } } } diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift new file mode 100644 index 000000000..3b985b722 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/ActiveSessionDetailView.swift @@ -0,0 +1,208 @@ +// +// 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 +import SwiftUI +import SwiftUIIntrospect + +struct ActiveSessionDetailView: View { + + @CurrentDate + private var currentDate: Date + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @ObservedObject + var box: BindingBox + + // MARK: Create Idle Content 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() + } + } + } + } + + // MARK: Create Session Content View + + @ViewBuilder + private func sessionContent( + session: SessionInfo, + nowPlayingItem: BaseItemDto, + playState: PlayerStateInfo + ) -> some View { + List { + + nowPlayingSection(item: nowPlayingItem) + + Section(L10n.progress) { + ActiveSessionsView.ProgressSection( + item: nowPlayingItem, + playState: playState, + transcodingInfo: session.transcodingInfo + ) + } + + 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) + } + } + + // TODO: allow showing item stream details? + // TODO: don't show codec changes on direct play? + Section(L10n.streams) { + if let playMethod = playState.playMethod { + TextPairView(leading: L10n.method, trailing: playMethod.displayTitle) + } + + StreamSection( + nowPlayingItem: nowPlayingItem, + transcodingInfo: session.transcodingInfo + ) + } + + if let transcodeReasons = session.transcodingInfo?.transcodeReasons, transcodeReasons.isNotEmpty { + Section(L10n.transcodeReasons) { + TranscodeSection(transcodeReasons: transcodeReasons) + } + } + } + } + + // MARK: Now Playing Section + + @ViewBuilder + private func nowPlayingSection(item: BaseItemDto) -> some View { + Section { + HStack(alignment: .bottom, spacing: 12) { + Group { + if item.type == .audio { + ZStack { + Color.clear + + ImageView(item.squareImageSources(maxWidth: 60)) + .failure { + SystemImageContentView(systemName: item.systemImage) + } + } + .squarePosterStyle() + } else { + ZStack { + Color.clear + + ImageView(item.portraitImageSources(maxWidth: 60)) + .failure { + SystemImageContentView(systemName: item.systemImage) + } + } + .posterStyle(.portrait) + } + } + .frame(width: 100) + .accessibilityIgnoresInvertColors() + + VStack(alignment: .leading) { + + if let parent = item.parentTitle { + Text(parent) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Text(item.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(2) + + if let subtitle = item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.bottom) + } + } + .listRowBackground(Color.clear) + .listRowCornerRadius(0) + .listRowInsets(.zero) + } + + var body: some View { + ZStack { + if let session = box.value { + if let nowPlayingItem = session.nowPlayingItem, let playState = session.playState { + sessionContent( + session: session, + nowPlayingItem: nowPlayingItem, + playState: playState + ) + } else { + idleContent(session: session) + } + } else { + Text(L10n.noSession) + } + } + .animation(.linear(duration: 0.2), value: box.value) + .navigationTitle(L10n.session) + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/StreamSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/StreamSection.swift new file mode 100644 index 000000000..023fb53d4 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/StreamSection.swift @@ -0,0 +1,67 @@ +// +// 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 ActiveSessionDetailView { + + struct StreamSection: View { + + let nowPlayingItem: BaseItemDto + let transcodingInfo: TranscodingInfo? + + // MARK: - Body + + var body: some View { + VStack(alignment: .leading) { + + // Create the Audio Codec Flow if the stream uses Audio + if let sourceAudioCodec = nowPlayingItem.mediaStreams?.first(where: { $0.type == .audio })?.codec { + getMediaComparison( + sourceComponent: sourceAudioCodec, + destinationComponent: transcodingInfo?.audioCodec ?? sourceAudioCodec + ) + } + + // Create the Video Codec Flow if the stream uses Video + if let sourceVideoCodec = nowPlayingItem.mediaStreams?.first(where: { $0.type == .video })?.codec { + getMediaComparison( + sourceComponent: sourceVideoCodec, + destinationComponent: transcodingInfo?.videoCodec ?? sourceVideoCodec + ) + } + + // Create the Container Flow if the stream has a Container + if let sourceContainer = nowPlayingItem.container { + getMediaComparison( + sourceComponent: sourceContainer, + destinationComponent: transcodingInfo?.container ?? sourceContainer + ) + } + } + } + + // MARK: - Transcoding Details + + @ViewBuilder + private func getMediaComparison(sourceComponent: String, destinationComponent: String) -> some View { + HStack { + Text(sourceComponent) + .frame(maxWidth: .infinity, alignment: .trailing) + + Image(systemName: (destinationComponent != sourceComponent) ? "shuffle" : "arrow.right") + .frame(maxWidth: .infinity, alignment: .center) + + Text(destinationComponent) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift new file mode 100644 index 000000000..e1cfdf1eb --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionDetailView/Components/TranscodeSection.swift @@ -0,0 +1,42 @@ +// +// 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 ActiveSessionDetailView { + + struct TranscodeSection: View { + + let transcodeReasons: [TranscodeReason] + + // MARK: - Body + + var body: some View { + VStack(alignment: .center) { + + let transcodeIcons = Set(transcodeReasons.map(\.systemImage)).sorted() + + HStack { + ForEach(transcodeIcons, id: \.self) { icon in + Image(systemName: icon) + .foregroundStyle(.primary) + } + } + + Divider() + + ForEach(transcodeReasons, id: \.self) { reason in + Text(reason) + .multilineTextAlignment(.center) + .lineLimit(2) + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift new file mode 100644 index 000000000..49507b85b --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/ActiveSessionsView.swift @@ -0,0 +1,87 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionVGrid +import Defaults +import JellyfinAPI +import SwiftUI + +// TODO: filter for streaming/inactive + +struct ActiveSessionsView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @StateObject + private var viewModel = ActiveSessionsViewModel() + + private let timer = Timer.publish(every: 5, on: .main, in: .common) + .autoconnect() + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + if viewModel.sessions.isEmpty { + L10n.noResults.text + } else { + CollectionVGrid( + viewModel.sessions.keys, + layout: .columns(1, insets: .zero, itemSpacing: 0, lineSpacing: 0) + ) { id in + ActiveSessionRow(box: viewModel.sessions[id]!) { + router.route( + to: \.activeDeviceDetails, + viewModel.sessions[id]! + ) + } + } + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refreshSessions) + } + } + + // MARK: - Body + + @ViewBuilder + var body: some View { + ZStack { + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial: + DelayedProgressView() + } + } + .navigationTitle(L10n.activeDevices) + .onFirstAppear { + viewModel.send(.refreshSessions) + } + .onReceive(timer) { _ in + viewModel.send(.getSessions) + } + .refreshable { + viewModel.send(.refreshSessions) + } + .topBarTrailing { + + if viewModel.backgroundStates.contains(.gettingSessions) { + ProgressView() + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift new file mode 100644 index 000000000..52bb1ca40 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ActiveSessionRow.swift @@ -0,0 +1,125 @@ +// +// 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: inactive session device image + +extension ActiveSessionsView { + + struct ActiveSessionRow: View { + + @CurrentDate + private var currentDate: Date + + @ObservedObject + private var box: BindingBox + + private let onSelect: () -> Void + + // parent list won't show row if value is nil anyways + private var session: SessionInfo { + box.value ?? .init() + } + + init(box: BindingBox, onSelect action: @escaping () -> Void) { + self.box = box + self.onSelect = action + } + + @ViewBuilder + private var rowLeading: some View { + // TODO: better handling for different poster types + Group { + if session.nowPlayingItem?.type == .audio { + ZStack { + Color.clear + + ImageView(session.nowPlayingItem?.squareImageSources(maxWidth: 60) ?? []) + .failure { + SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) + } + } + .squarePosterStyle() + } else { + ZStack { + Color.clear + + ImageView(session.nowPlayingItem?.portraitImageSources(maxWidth: 60) ?? []) + .failure { + SystemImageContentView(systemName: session.nowPlayingItem?.systemImage) + } + } + .posterStyle(.portrait) + } + } + .frame(width: 60) + .posterShadow() + .padding(.vertical, 8) + } + + @ViewBuilder + private func activeSessionDetails(_ nowPlayingItem: BaseItemDto, playState: PlayerStateInfo) -> some View { + VStack(alignment: .leading) { + Text(session.userName ?? L10n.unknown) + .font(.headline) + + Text(nowPlayingItem.name ?? L10n.unknown) + + ProgressSection( + item: nowPlayingItem, + playState: playState, + transcodingInfo: session.transcodingInfo + ) + } + .font(.subheadline) + } + + @ViewBuilder + private var idleSessionDetails: some View { + VStack(alignment: .leading) { + + Text(session.userName ?? L10n.unknown) + .font(.headline) + + if let client = session.client { + TextPairView(leading: L10n.client, trailing: client) + } + + if let device = session.deviceName { + TextPairView(leading: L10n.device, trailing: device) + } + + if let lastActivityDate = session.lastActivityDate { + TextPairView( + L10n.lastSeen, + value: Text(lastActivityDate, format: .relative(presentation: .numeric, unitsStyle: .narrow)) + ) + .id(currentDate) + .monospacedDigit() + } + } + .font(.subheadline) + } + + var body: some View { + ListRow(insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding)) { + rowLeading + } content: { + if let nowPlayingItem = session.nowPlayingItem, let playState = session.playState { + activeSessionDetails(nowPlayingItem, playState: playState) + } else { + idleSessionDetails + } + } + .onSelect(perform: onSelect) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ProgressSection.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ProgressSection.swift new file mode 100644 index 000000000..e736c298b --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ActiveSessionsView/Components/ProgressSection.swift @@ -0,0 +1,73 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension ActiveSessionsView { + + struct ProgressSection: View { + + @Default(.accentColor) + private var accentColor + + let item: BaseItemDto + let playState: PlayerStateInfo + let transcodingInfo: TranscodingInfo? + + private var playbackPercentage: Double { + clamp(Double(playState.positionTicks ?? 0) / Double(item.runTimeTicks ?? 1), min: 0, max: 1) + } + + private var transcodingPercentage: Double? { + guard let c = transcodingInfo?.completionPercentage else { return nil } + return clamp(c / 100.0, min: 0, max: 1) + } + + @ViewBuilder + private var playbackInformation: some View { + HStack { + if playState.isPaused ?? false { + Image(systemName: "pause.fill") + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + } else { + Image(systemName: "play.fill") + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + } + + if let playMethod = playState.playMethod, playMethod == .transcode { + Text(playMethod) + } + + Spacer() + + HStack(spacing: 2) { + Text(playState.positionSeconds ?? 0, format: .runtime) + + Text("/") + + Text(item.runTimeSeconds, format: .runtime) + } + .monospacedDigit() + } + .font(.subheadline) + } + + var body: some View { + VStack { + ProgressView(value: playbackPercentage) + .progressViewStyle(.playback(secondaryProgress: transcodingPercentage)) + .frame(height: 5) + .foregroundStyle(.primary, .secondary, .orange) + + playbackInformation + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift new file mode 100644 index 000000000..968ecb41b --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/EditScheduledTaskView.swift @@ -0,0 +1,95 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +// TODO: last run details +// - result, show error if available +// TODO: observe running status +// - stop +// - run +// - progress +// TODO: triggers + +struct EditScheduledTaskView: View { + + @CurrentDate + private var currentDate: Date + + @ObservedObject + var observer: ServerTaskObserver + + var body: some View { + List { + + ListTitleSection( + observer.task.name ?? L10n.unknown, + description: observer.task.description + ) + + if let category = observer.task.category { + TextPairView( + leading: L10n.category, + trailing: category + ) + } + + if let lastEndTime = observer.task.lastExecutionResult?.endTimeUtc { + TextPairView( + L10n.lastRun, + value: Text("\(lastEndTime, format: .relative(presentation: .numeric, unitsStyle: .narrow))") + ) + .id(currentDate) + .monospacedDigit() + + if let lastStartTime = observer.task.lastExecutionResult?.startTimeUtc { + TextPairView( + L10n.runtime, + value: Text( + "\(lastStartTime ..< lastEndTime, format: .components(style: .narrow))" + ) + ) + } + } + } + .navigationTitle(L10n.task) + } +} + +// TODO: remove after view done +#Preview { + NavigationView { + EditScheduledTaskView( + observer: .init( + task: TaskInfo( + category: "test", + currentProgressPercentage: nil, + description: "A test task", + id: "123", + isHidden: false, + key: "123", + lastExecutionResult: TaskResult( + endTimeUtc: Date(timeIntervalSinceNow: -10), + errorMessage: nil, + id: nil, + key: nil, + longErrorMessage: nil, + name: nil, + startTimeUtc: Date(timeIntervalSinceNow: -30), + status: .completed + ), + name: "Test", + state: .running, + triggers: nil + ) + ) + ) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift new file mode 100644 index 000000000..4c5f92b4f --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ScheduledTaskButton.swift @@ -0,0 +1,134 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import Stinsen +import SwiftUI + +extension ScheduledTasksView { + + struct ScheduledTaskButton: View { + + @CurrentDate + private var currentDate: Date + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @ObservedObject + var observer: ServerTaskObserver + + @State + private var isPresentingConfirmation = false + + // MARK: - Task Details Section + + @ViewBuilder + private var taskView: some View { + VStack(alignment: .leading, spacing: 4) { + + Text(observer.task.name ?? L10n.unknown) + .fontWeight(.semibold) + + taskResultView + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + // MARK: - Task Status Section + + @ViewBuilder + private var statusView: some View { + switch observer.state { + case .running: + ProgressView(value: (observer.task.currentProgressPercentage ?? 0) / 100) + .progressViewStyle(.gauge(systemImage: "stop.fill")) + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + default: + Image(systemName: "play.fill") + .foregroundStyle(.secondary) + .transition(.opacity.combined(with: .scale).animation(.bouncy)) + } + } + + // MARK: - Task Status View + + @ViewBuilder + private var taskResultView: some View { + if observer.state == .running { + Text(L10n.running) + } else if observer.task.state == .cancelling { + Text(L10n.cancelling) + } else { + if let taskEndTime = observer.task.lastExecutionResult?.endTimeUtc { + Text(L10n.lastRunTime(Date.RelativeFormatStyle(presentation: .numeric, unitsStyle: .narrow).format(taskEndTime))) + .id(currentDate) + .monospacedDigit() + } else { + Text(L10n.neverRun) + } + + if let status = observer.task.lastExecutionResult?.status, status != .completed { + Label( + status.displayTitle, + systemImage: "exclamationmark.circle.fill" + ) + .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) + .foregroundStyle(.orange) + .backport + .fontWeight(.semibold) + } + } + } + + @ViewBuilder + var body: some View { + Button { + isPresentingConfirmation = true + } label: { + HStack { + taskView + + Spacer() + + statusView + .frame(width: 25, height: 25) + } + } + .animation(.linear(duration: 0.1), value: observer.state) + .foregroundStyle(.primary, .secondary) + .confirmationDialog( + observer.task.name ?? .emptyDash, + isPresented: $isPresentingConfirmation, + titleVisibility: .visible + ) { + Group { + if observer.state == .running { + Button(L10n.stop) { + observer.send(.stop) + } + } else { + Button(L10n.run) { + observer.send(.start) + } + } + } + .disabled(observer.task.state == .cancelling) + + Button(L10n.edit) { + router.route(to: \.editScheduledTask, observer) + } + } message: { + if let description = observer.task.description { + Text(description) + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift new file mode 100644 index 000000000..d71f5a29a --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/Components/ServerTaskButton.swift @@ -0,0 +1,48 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension ScheduledTasksView { + + struct ServerTaskButton: View { + + let title: String + let systemImage: String + let warningMessage: String + let isPresented: Binding + let action: () -> Void + + // MARK: - Body + + var body: some View { + Button(role: .destructive) { + isPresented.wrappedValue = true + } label: { + HStack { + Text(title) + + Spacer() + + Image(systemName: systemImage) + } + } + .confirmationDialog( + title, + isPresented: isPresented, + titleVisibility: .hidden + ) { + Button(title, role: .destructive, action: action) + } message: { + Text(warningMessage) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift new file mode 100644 index 000000000..b9b68d8ec --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ScheduledTasksView/ScheduledTasksView.swift @@ -0,0 +1,111 @@ +// +// 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: refactor after socket implementation + +struct ScheduledTasksView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + @State + private var isPresentingRestartConfirmation = false + @State + private var isPresentingShutdownConfirmation = false + + @StateObject + private var viewModel = ScheduledTasksViewModel() + + private let timer = Timer.publish(every: 5, on: .main, in: .common) + .autoconnect() + + // MARK: - Server Function Buttons + + @ViewBuilder + private var serverFunctions: some View { + ServerTaskButton( + title: L10n.restartServer, + systemImage: "arrow.clockwise", + warningMessage: L10n.restartWarning, + isPresented: $isPresentingRestartConfirmation + ) { + viewModel.send(.restartApplication) + } + + ServerTaskButton( + title: L10n.shutdownServer, + systemImage: "power", + warningMessage: L10n.shutdownWarning, + isPresented: $isPresentingShutdownConfirmation + ) { + viewModel.send(.shutdownApplication) + } + } + + // MARK: - Body + + @ViewBuilder + private var contentView: some View { + List { + + ListTitleSection( + L10n.tasks, + description: L10n.tasksDescription + ) { + UIApplication.shared.open(URL(string: "https://jellyfin.org/docs/general/server/tasks")!) + } + + Section(L10n.server) { + serverFunctions + } + + ForEach(viewModel.tasks.keys, id: \.self) { category in + Section(category) { + ForEach(viewModel.tasks[category] ?? []) { task in + ScheduledTaskButton(observer: task) + } + } + } + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refreshTasks) + } + } + + var body: some View { + ZStack { + Color.clear + + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(L10n.tasks) + .onFirstAppear { + viewModel.send(.refreshTasks) + } + .onReceive(timer) { _ in + viewModel.send(.getTasks) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift new file mode 100644 index 000000000..376ecc25a --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/ServerLogsView.swift @@ -0,0 +1,81 @@ +// +// 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: could filter based on known log names from server +// - ffmpeg +// - record-transcode +// TODO: download to device? +// TODO: super cool log parser? +// - separate package + +struct ServerLogsView: View { + + @StateObject + private var viewModel = ServerLogsViewModel() + + @ViewBuilder + private var contentView: some View { + List { + ForEach(viewModel.logs, id: \.self) { log in + Button { + let request = Paths.getLogFile(name: log.name!) + let url = viewModel.userSession.client.fullURL(with: request, queryAPIKey: true)! + + UIApplication.shared.open(url) + } label: { + HStack { + VStack(alignment: .leading) { + Text(log.name ?? .emptyDash) + + if let modifiedDate = log.dateModified { + Text(modifiedDate, format: .dateTime) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "arrow.up.forward") + .font(.body.weight(.regular)) + .foregroundColor(.secondary) + } + } + .foregroundStyle(.primary, .secondary) + } + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.getLogs) + } + } + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial: + DelayedProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationBarTitle(L10n.serverLogs) + .onFirstAppear { + viewModel.send(.getLogs) + } + } +} diff --git a/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift new file mode 100644 index 000000000..f939796b9 --- /dev/null +++ b/Swiftfin/Views/SettingsView/UserDashboardView/UserDashboardView.swift @@ -0,0 +1,46 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct UserDashboardView: View { + + @EnvironmentObject + private var router: SettingsCoordinator.Router + + // MARK: - Body + + var body: some View { + List { + + ListTitleSection( + L10n.dashboard, + description: L10n.dashboardDescription + ) + + ChevronButton(L10n.activeDevices) + .onSelect { + router.route(to: \.activeSessions) + } + + Section(L10n.advanced) { + + ChevronButton(L10n.logs) + .onSelect { + router.route(to: \.serverLogs) + } + + ChevronButton(L10n.tasks) + .onSelect { + router.route(to: \.tasks) + } + } + } + .navigationTitle(L10n.dashboard) + } +} diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index d67f9b0d7..c941137e3 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ