[iOS] Admin Dashboard (#1230)
Some checks are pending
Build 🔨 / Build 🔨 (Swiftfin tvOS) (push) Waiting to run
Build 🔨 / Build 🔨 (Swiftfin) (push) Waiting to run

This commit is contained in:
Joe 2024-10-04 21:15:12 -06:00 committed by GitHub
parent 4cba762226
commit bc9eacab57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 2660 additions and 78 deletions

View File

@ -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)
}
}
.animation(.linear(duration: 0.1), value: progress)
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)
}
}
.trackingSize($contentSize)
}
}
// #Preview {
// ProgressView(value: 0.3)
// .progressViewStyle(.SwiftfinLinear(secondaryProgress: 0.3))
// .frame(height: 8)
// .padding(.horizontal, 10)
// .foregroundStyle(.primary, .secondary, .orange)
// }

View File

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

View File

@ -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<SessionInfo?>) -> some View {
ActiveSessionDetailView(box: box)
}
func makeItemOverviewView(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
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)

View File

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

View File

@ -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:
[]
}
}
}

View File

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

View File

@ -13,7 +13,7 @@ import UIKit
extension JellyfinClient {
func fullURL<T>(with request: Request<T>) -> URL? {
func fullURL<T>(with request: Request<T>, 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<CGSize, CGFloat>) -> some View {
modifier(OnSizeChangedModifier { size in
overlay {

View File

@ -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<Date> {
$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
}
}
}
}
}

View File

@ -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] {
[]
}
}

View File

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

View File

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

View File

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

View File

@ -89,6 +89,14 @@ extension StoredValues.Keys {
)
}
static var accessPolicy: Key<UserAccessPolicy> {
CurrentUserKey(
"currentUserAccessPolicy",
domain: "currentUserAccessPolicy",
default: .none
)
}
static func libraryDisplayType(parentID: String?) -> Key<LibraryDisplayType> {
CurrentUserKey(
parentID,

View File

@ -64,6 +64,10 @@ extension UserState {
}
}
var isAdministrator: Bool {
data.policy?.isAdministrator ?? false
}
var pinHint: String {
get {
StoredValues[.User.pinHint(id: id)]

View File

@ -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<BackgroundState> = []
@Published
final var sessions: OrderedDictionary<String, BindingBox<SessionInfo?>> = [:]
@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<SessionInfo?>(
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)
}
}
}
}
}

View File

@ -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<BackgroundState> = []
@Published
final var state: State = .initial
@Published
final var tasks: OrderedDictionary<String, [ServerTaskObserver]> = [:]
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)
}
}

View File

@ -10,7 +10,7 @@ import CoreStore
import Foundation
import JellyfinAPI
class EditServerViewModel: ViewModel {
class ServerConnectionViewModel: ViewModel {
@Published
var server: ServerState

View File

@ -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<LogFile> = []
@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<LogFile> {
let request = Paths.getServerLogs
let response = try await userSession.client.send(request)
return OrderedSet(response.value)
}
}

View File

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

View File

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

View File

@ -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 = "<group>"; };
4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = "<group>"; };
4E16FD502C0183DB00110147 /* LetterPickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerButton.swift; sourceTree = "<group>"; };
4E16FD522C01840C00110147 /* LetterPickerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerBar.swift; sourceTree = "<group>"; };
4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerOrientation.swift; sourceTree = "<group>"; };
4E182C9B2C94993200FBEFD5 /* ScheduledTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTasksView.swift; sourceTree = "<group>"; };
4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTaskButton.swift; sourceTree = "<group>"; };
4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = "<group>"; };
4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = "<group>"; };
4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileAction.swift; sourceTree = "<group>"; };
4E2AC4C12C6C491200DD600D /* AudoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudoCodec.swift; sourceTree = "<group>"; };
4E2AC4C42C6C492700DD600D /* MediaContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainer.swift; sourceTree = "<group>"; };
@ -993,6 +1031,9 @@
4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDashboardView.swift; sourceTree = "<group>"; };
4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = "<group>"; };
4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionDetailView.swift; sourceTree = "<group>"; };
4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = "<group>"; };
4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = "<group>"; };
4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = "<group>"; };
@ -1001,6 +1042,10 @@
4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = "<group>"; };
4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = "<group>"; };
4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = "<group>"; };
4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = "<group>"; };
4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = "<group>"; };
4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskButton.swift; sourceTree = "<group>"; };
4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionRow.swift; sourceTree = "<group>"; };
4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCompatibility.swift; sourceTree = "<group>"; };
4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = "<group>"; };
4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = "<group>"; };
@ -1008,6 +1053,10 @@
4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileCoordinator.swift; sourceTree = "<group>"; };
4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = "<group>"; };
4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = "<group>"; };
4EC50D602C934B3A00FC3D0E /* ScheduledTasksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduledTasksViewModel.swift; sourceTree = "<group>"; };
4EC6C16A2C92999800FC904B /* TranscodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeSection.swift; sourceTree = "<group>"; };
4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodeReason.swift; sourceTree = "<group>"; };
4EE141682C8BABDF0045B661 /* ProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressSection.swift; sourceTree = "<group>"; };
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; };
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
@ -1351,9 +1400,9 @@
E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageBlurHashes.swift; sourceTree = "<group>"; };
E172D3AC2BAC9DF8007B4647 /* SeasonItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeasonItemViewModel.swift; sourceTree = "<group>"; };
E172D3B12BACA569007B4647 /* EpisodeContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeContent.swift; sourceTree = "<group>"; };
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = "<group>"; };
E173DA4F26D048D600CC4EB7 /* EditServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerView.swift; sourceTree = "<group>"; };
E173DA5126D04AAF00CC4EB7 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = "<group>"; };
E173DA5326D050F500CC4EB7 /* ServerConnectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnectionViewModel.swift; sourceTree = "<group>"; };
E174120E29AE9D94003EF3B5 /* NavigationCoordinatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCoordinatable.swift; sourceTree = "<group>"; };
E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSettingsView.swift; sourceTree = "<group>"; };
E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionMenu.swift; sourceTree = "<group>"; };
@ -1478,6 +1527,7 @@
E1B33ED028EB860A0073B0FD /* LargePlaybackButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargePlaybackButtons.swift; sourceTree = "<group>"; };
E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentLogHandler.swift; sourceTree = "<group>"; };
E1B490462967E2E500D3EDCE /* CoreStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreStore.swift; sourceTree = "<group>"; };
E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedDictionary.swift; sourceTree = "<group>"; };
E1B5784028F8AFCB00D42911 /* WrappedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedView.swift; sourceTree = "<group>"; };
E1B5861129E32EEF00E45D6E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = "<group>"; };
E1B90C692BBE68D5007027C8 /* OffsetScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetScrollView.swift; sourceTree = "<group>"; };
@ -1605,10 +1655,18 @@
E1EA9F6928F8A79E00BEC442 /* VideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerManager.swift; sourceTree = "<group>"; };
E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TruncatedText.swift; sourceTree = "<group>"; };
E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemOverviewView.swift; sourceTree = "<group>"; };
E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditScheduledTaskView.swift; sourceTree = "<group>"; };
E1ED7FD72CA8AF7400ACB6E3 /* ServerTaskObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskObserver.swift; sourceTree = "<group>"; };
E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStateInfo.swift; sourceTree = "<group>"; };
E1ED7FDD2CAA641F00ACB6E3 /* ListTitleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTitleSection.swift; sourceTree = "<group>"; };
E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerLogsView.swift; sourceTree = "<group>"; };
E1ED7FE12CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerLogsViewModel.swift; sourceTree = "<group>"; };
E1ED91142B95897500802036 /* LatestInLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestInLibraryViewModel.swift; sourceTree = "<group>"; };
E1ED91172B95993300802036 /* TitledLibraryParent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitledLibraryParent.swift; sourceTree = "<group>"; };
E1EF4C402911B783008CC695 /* StreamType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamType.swift; sourceTree = "<group>"; };
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = "<group>"; };
E1F5CF042CB09EA000607465 /* CurrentDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentDate.swift; sourceTree = "<group>"; };
E1F5CF072CB0A04500607465 /* Text.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Text.swift; sourceTree = "<group>"; };
E1FA891A289A302300176FEB /* iPadOSCollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemView.swift; sourceTree = "<group>"; };
E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemContentView.swift; sourceTree = "<group>"; };
E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
@ -1755,6 +1813,24 @@
path = Components;
sourceTree = "<group>";
};
4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */ = {
isa = PBXGroup;
children = (
4E182C9D2C94A01600FBEFD5 /* Components */,
4E182C9B2C94993200FBEFD5 /* ScheduledTasksView.swift */,
);
path = ScheduledTasksView;
sourceTree = "<group>";
};
4E182C9D2C94A01600FBEFD5 /* Components */ = {
isa = PBXGroup;
children = (
4E182C9E2C94A1E000FBEFD5 /* ScheduledTaskButton.swift */,
4EB1A8CB2C9B1B9700F43898 /* ServerTaskButton.swift */,
);
path = Components;
sourceTree = "<group>";
};
4E2AC4C02C6C48EB00DD600D /* MediaComponents */ = {
isa = PBXGroup;
children = (
@ -1775,6 +1851,28 @@
path = PlaybackBitrate;
sourceTree = "<group>";
};
4E63B9F52C8A5BEF00C25378 /* UserDashboardView */ = {
isa = PBXGroup;
children = (
4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */,
4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */,
E1ED7FD52CA8A7FD00ACB6E3 /* EditScheduledTaskView.swift */,
4E182C9A2C94991800FBEFD5 /* ScheduledTasksView */,
E1ED7FDF2CAA685900ACB6E3 /* ServerLogsView.swift */,
4E63B9F42C8A5BEF00C25378 /* UserDashboardView.swift */,
);
path = UserDashboardView;
sourceTree = "<group>";
};
4E6C27062C8BD09200FD2185 /* ActiveSessionDetailView */ = {
isa = PBXGroup;
children = (
4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */,
4EB1A8D32C9B91A200F43898 /* Components */,
);
path = ActiveSessionDetailView;
sourceTree = "<group>";
};
4E9A24E32C82B4700023DA83 /* CustomDeviceProfileSettingsView */ = {
isa = PBXGroup;
children = (
@ -1793,6 +1891,33 @@
path = Components;
sourceTree = "<group>";
};
4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */ = {
isa = PBXGroup;
children = (
4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */,
4EB1A8D02C9B2FB600F43898 /* Components */,
);
path = ActiveSessionsView;
sourceTree = "<group>";
};
4EB1A8D02C9B2FB600F43898 /* Components */ = {
isa = PBXGroup;
children = (
4EB1A8CD2C9B2D0100F43898 /* ActiveSessionRow.swift */,
4EE141682C8BABDF0045B661 /* ProgressSection.swift */,
);
path = Components;
sourceTree = "<group>";
};
4EB1A8D32C9B91A200F43898 /* Components */ = {
isa = PBXGroup;
children = (
4EB1404B2C8E45B1008691F3 /* StreamSection.swift */,
4EC6C16A2C92999800FC904B /* TranscodeSection.swift */,
);
path = Components;
sourceTree = "<group>";
};
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 */,

View File

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

View File

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

View File

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

View File

@ -42,6 +42,8 @@ struct EpisodeSelectorLabelStyle: LabelStyle {
// MARK: SectionFooterWithImageLabelStyle
// TODO: rename as not only used in section footers
extension LabelStyle where Self == SectionFooterWithImageLabelStyle<AnyShapeStyle> {
static func sectionFooterWithImage<ImageStyle: ShapeStyle>(imageStyle: ImageStyle) -> SectionFooterWithImageLabelStyle<ImageStyle> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
*/

View File

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

View File

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

View File

@ -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<SessionInfo?>
// 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)
}
}

View File

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

View File

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

View File

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

View File

@ -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<SessionInfo?>
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<SessionInfo?>, 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)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,46 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import 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)
}
}