diff --git a/jellypig iOS/App/AppDelegate.swift b/jellypig iOS/App/AppDelegate.swift deleted file mode 100644 index f3fd3914..00000000 --- a/jellypig iOS/App/AppDelegate.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import AVFAudio -import CoreStore -import Defaults -import Logging -import Pulse -import PulseLogHandler -import SwiftUI -import UIKit - -class AppDelegate: NSObject, UIApplicationDelegate { - - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.playback) - } catch { - print("setting category AVAudioSessionCategoryPlayback failed") - } - - return true - } -} diff --git a/jellypig iOS/App/SwiftfinApp+ValueObservation.swift b/jellypig iOS/App/SwiftfinApp+ValueObservation.swift deleted file mode 100644 index fc8098d7..00000000 --- a/jellypig iOS/App/SwiftfinApp+ValueObservation.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import Foundation -import SwiftUI - -// Following class is necessary to observe values that can either -// be a user *or* an app setting and only one should apply at a time. -// -// Also just to separate out value observation - -// TODO: could clean up? - -extension SwiftfinApp { - - class ValueObservation: ObservableObject { - - private var accentColorCancellable: AnyCancellable? - private var appearanceCancellable: AnyCancellable? - private var lastSignInUserIDCancellable: AnyCancellable? - private var splashScreenCancellable: AnyCancellable? - - init() { - - // MARK: signed in observation - - lastSignInUserIDCancellable = Task { - for await newValue in Defaults.updates(.lastSignedInUserID) { - if case .signedIn = newValue { - setUserDefaultsObservation() - } else { - setAppDefaultsObservation() - } - } - } - .asAnyCancellable() - } - - // MARK: user observation - - private func setUserDefaultsObservation() { - accentColorCancellable?.cancel() - appearanceCancellable?.cancel() - splashScreenCancellable?.cancel() - - accentColorCancellable = Task { - for await newValue in Defaults.updates(.userAccentColor) { - await MainActor.run { - Defaults[.accentColor] = newValue - UIApplication.shared.setAccentColor(newValue.uiColor) - } - } - } - .asAnyCancellable() - - appearanceCancellable = Task { - for await newValue in Defaults.updates(.userAppearance) { - await MainActor.run { - Defaults[.appearance] = newValue - UIApplication.shared.setAppearance(newValue.style) - } - } - } - .asAnyCancellable() - } - - // MARK: app observation - - private func setAppDefaultsObservation() { - accentColorCancellable?.cancel() - appearanceCancellable?.cancel() - splashScreenCancellable?.cancel() - - accentColorCancellable = Task { - for await newValue in Defaults.updates(.appAccentColor) { - await MainActor.run { - Defaults[.accentColor] = newValue - UIApplication.shared.setAccentColor(newValue.uiColor) - } - } - } - .asAnyCancellable() - - appearanceCancellable = Task { - for await newValue in Defaults.updates(.appAppearance) { - - // other cancellable will set appearance if enabled - // and need to avoid races - guard !Defaults[.selectUserUseSplashscreen] else { continue } - - await MainActor.run { - Defaults[.appearance] = newValue - UIApplication.shared.setAppearance(newValue.style) - } - } - } - .asAnyCancellable() - - splashScreenCancellable = Task { - for await newValue in Defaults.updates(.selectUserUseSplashscreen) { - await MainActor.run { - if newValue { - Defaults[.appearance] = .dark - UIApplication.shared.setAppearance(.dark) - } else { - Defaults[.appearance] = Defaults[.appAppearance] - UIApplication.shared.setAppearance(Defaults[.appAppearance].style) - } - } - } - } - .asAnyCancellable() - } - } -} diff --git a/jellypig iOS/App/SwiftfinApp.swift b/jellypig iOS/App/SwiftfinApp.swift deleted file mode 100644 index 3636706a..00000000 --- a/jellypig iOS/App/SwiftfinApp.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CoreStore -import Defaults -import Factory -import Logging -import Nuke -import PreferencesView -import Pulse -import PulseLogHandler -import SwiftUI - -@main -struct SwiftfinApp: App { - - @UIApplicationDelegateAdaptor(AppDelegate.self) - var appDelegate - - @StateObject - private var valueObservation = ValueObservation() - - init() { - - // CoreStore - - CoreStoreDefaults.dataStack = SwiftfinStore.dataStack - CoreStoreDefaults.logger = SwiftfinCorestoreLogger() - - // Logging - LoggingSystem.bootstrap { label in - - var loggers: [LogHandler] = [PersistentLogHandler(label: label).withLogLevel(.trace)] - - #if DEBUG - loggers.append(SwiftfinConsoleLogger()) - #endif - - return MultiplexLogHandler(loggers) - } - - // Nuke - - ImageCache.shared.costLimit = 1024 * 1024 * 200 // 200 MB - ImageCache.shared.ttl = 300 // 5 min - - ImageDecoderRegistry.shared.register { context in - guard let mimeType = context.urlResponse?.mimeType else { return nil } - return mimeType.contains("svg") ? ImageDecoders.Empty() : nil - } - - ImagePipeline.shared = .Swiftfin.posters - - // UIKit - - UIScrollView.appearance().keyboardDismissMode = .onDrag - - // Sometimes the tab bar won't appear properly on push, always have material background - UITabBar.appearance().scrollEdgeAppearance = UITabBarAppearance(idiom: .unspecified) - - // Swiftfin - - // don't keep last user id - if Defaults[.signOutOnClose] { - Defaults[.lastSignedInUserID] = .signedOut - } - } - - // TODO: removed after iOS 15 support removed - - @ViewBuilder - private var versionedView: some View { - if #available(iOS 16, *) { - PreferencesView { - MainCoordinator() - .view() - .supportedOrientations(UIDevice.isPad ? .allButUpsideDown : .portrait) - } - } else { - PreferencesView { - PreferencesView { - MainCoordinator() - .view() - .supportedOrientations(UIDevice.isPad ? .allButUpsideDown : .portrait) - } - .ignoresSafeArea() - } - } - } - - var body: some Scene { - WindowGroup { - versionedView - .ignoresSafeArea() - .onNotification(.applicationDidEnterBackground) { - Defaults[.backgroundTimeStamp] = Date.now - } - .onNotification(.applicationWillEnterForeground) { - - // TODO: needs to check if any background playback is happening - // - atow, background video playback isn't officially supported - let backgroundedInterval = Date.now.timeIntervalSince(Defaults[.backgroundTimeStamp]) - - if Defaults[.signOutOnBackground], backgroundedInterval > Defaults[.backgroundSignOutInterval] { - Defaults[.lastSignedInUserID] = .signedOut - Container.shared.currentUserSession.reset() - Notifications[.didSignOut].post() - } - } - } - } -} - -extension UINavigationController { - - // Remove back button text - override open func viewWillLayoutSubviews() { - navigationBar.topItem?.backButtonDisplayMode = .minimal - } -} diff --git a/jellypig iOS/Components/BasicStepper.swift b/jellypig iOS/Components/BasicStepper.swift deleted file mode 100644 index 5c48123f..00000000 --- a/jellypig iOS/Components/BasicStepper.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct BasicStepper: View { - - @Binding - private var value: Value - - private let title: String - private let range: ClosedRange - private let step: Value.Stride - private var formatter: (Value) -> String - - var body: some View { - Stepper(value: $value, in: range, step: step) { - HStack { - Text(title) - - Spacer() - - formatter(value).text - .foregroundColor(.secondary) - } - } - } -} - -extension BasicStepper { - - init( - title: String, - value: Binding, - range: ClosedRange, - step: Value.Stride - ) { - self.init( - value: value, - title: title, - range: range, - step: step, - formatter: { $0.description } - ) - } - - func valueFormatter(_ formatter: @escaping (Value) -> String) -> Self { - copy(modifying: \.formatter, with: formatter) - } -} diff --git a/jellypig iOS/Components/CircularProgressView.swift b/jellypig iOS/Components/CircularProgressView.swift deleted file mode 100644 index 1ec7a6de..00000000 --- a/jellypig iOS/Components/CircularProgressView.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// SwiftUI gauge style not available on iOS 15 - -struct GaugeProgressStyle: ProgressViewStyle { - - @Default(.accentColor) - private var accentColor - - @State - private var contentSize: CGSize = .zero - - private var lineWidthRatio: CGFloat - private var systemImage: String? - - 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.gray.opacity(0.2), - lineWidth: contentSize.width / lineWidthRatio - ) - - Circle() - .trim(from: 0, to: configuration.fractionCompleted ?? 0) - .stroke( - accentColor, - style: StrokeStyle( - lineWidth: contentSize.width / lineWidthRatio, - lineCap: .round - ) - ) - .rotationEffect(.degrees(-90)) - } - .animation(.linear(duration: 0.1), value: configuration.fractionCompleted) - .trackingSize($contentSize) - } -} - -extension GaugeProgressStyle { - - init() { - self.init( - lineWidthRatio: 5, - systemImage: nil - ) - } - - init(systemImage: String) { - self.init( - lineWidthRatio: 8, - systemImage: systemImage - ) - } -} - -extension ProgressViewStyle where Self == GaugeProgressStyle { - - static var gauge: GaugeProgressStyle { - GaugeProgressStyle() - } - - static func gauge(systemImage: String) -> GaugeProgressStyle { - GaugeProgressStyle(systemImage: systemImage) - } -} diff --git a/jellypig iOS/Components/CountryPicker.swift b/jellypig iOS/Components/CountryPicker.swift deleted file mode 100644 index f8049df4..00000000 --- a/jellypig iOS/Components/CountryPicker.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct CountryPicker: View { - let title: String - @Binding - var selectedCountryCode: String? - - // MARK: - Get all localized countries - - private var countries: [(code: String?, name: String)] { - var uniqueCountries = Set() - - var countryList: [(code: String?, name: String)] = Locale.isoRegionCodes.compactMap { code in - let locale = Locale.current - if let name = locale.localizedString(forRegionCode: code), - !uniqueCountries.contains(code) - { - uniqueCountries.insert(code) - return (code, name) - } - return nil - } - .sorted { $0.name < $1.name } - - // Add None as an option at the top of the list - countryList.insert((code: nil, name: L10n.none), at: 0) - return countryList - } - - // MARK: - Body - - var body: some View { - Picker(title, selection: $selectedCountryCode) { - ForEach(countries, id: \.code) { country in - Text(country.name).tag(country.code) - } - } - } -} diff --git a/jellypig iOS/Components/DelayedProgressView.swift b/jellypig iOS/Components/DelayedProgressView.swift deleted file mode 100644 index a55e738f..00000000 --- a/jellypig iOS/Components/DelayedProgressView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import SwiftUI - -// TODO: retry button and/or loading text after a few more seconds -struct DelayedProgressView: View { - - @State - private var interval = 0 - - private let timer: Publishers.Autoconnect - - init(interval: Double = 0.5) { - self.timer = Timer.publish(every: interval, on: .main, in: .common).autoconnect() - } - - var body: some View { - VStack { - if interval > 0 { - ProgressView() - } - } - .onReceive(timer) { _ in - withAnimation { - interval += 1 - } - } - } -} diff --git a/jellypig iOS/Components/DotHStack.swift b/jellypig iOS/Components/DotHStack.swift deleted file mode 100644 index 0a3f6822..00000000 --- a/jellypig iOS/Components/DotHStack.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct DotHStack: View { - - @ViewBuilder - var content: () -> Content - - var body: some View { - SeparatorHStack(content) - .separator { - Circle() - .frame(width: 2, height: 2) - .padding(.horizontal, 5) - } - } -} diff --git a/jellypig iOS/Components/ErrorView.swift b/jellypig iOS/Components/ErrorView.swift deleted file mode 100644 index ba68f514..00000000 --- a/jellypig iOS/Components/ErrorView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: should use environment refresh instead? -struct ErrorView: View { - - private let error: ErrorType - private var onRetry: (() -> Void)? - - var body: some View { - VStack(spacing: 20) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 72)) - .foregroundColor(Color.red) - - Text(error.localizedDescription) - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) - - if let onRetry { - PrimaryButton(title: L10n.retry) - .onSelect(onRetry) - .frame(maxWidth: 300) - .frame(height: 50) - } - } - } -} - -extension ErrorView { - - init(error: ErrorType) { - self.init( - error: error, - onRetry: nil - ) - } - - func onRetry(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onRetry, with: action) - } -} diff --git a/jellypig iOS/Components/GestureView.swift b/jellypig iOS/Components/GestureView.swift deleted file mode 100644 index 9a48541e..00000000 --- a/jellypig iOS/Components/GestureView.swift +++ /dev/null @@ -1,320 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Foundation -import SwiftUI - -// TODO: change swipe to directional -// TODO: figure out way for multitap near the middle be distinguished as different sides - -// state, point, velocity, translation -typealias PanGestureHandler = (UIGestureRecognizer.State, UnitPoint, CGFloat, CGFloat) -> Void -// state, point, scale -typealias PinchGestureHandler = (UIGestureRecognizer.State, UnitPoint, CGFloat) -> Void -// point, direction, amount -typealias SwipeGestureHandler = (UnitPoint, Bool, Int) -> Void -// point, amount -typealias TapGestureHandler = (UnitPoint, Int) -> Void - -struct GestureView: UIViewRepresentable { - - private var onHorizontalPan: PanGestureHandler? - private var onHorizontalSwipe: SwipeGestureHandler? - private var onLongPress: ((UnitPoint) -> Void)? - private var onPinch: PinchGestureHandler? - private var onTap: TapGestureHandler? - private var onDoubleTouch: TapGestureHandler? - private var onVerticalPan: PanGestureHandler? - - private var longPressMinimumDuration: TimeInterval - private var samePointPadding: CGFloat - private var samePointTimeout: TimeInterval - private var swipeTranslation: CGFloat - private var swipeVelocity: CGFloat - private var sameSwipeDirectionTimeout: TimeInterval - - func makeUIView(context: Context) -> UIGestureView { - UIGestureView( - onHorizontalPan: onHorizontalPan, - onHorizontalSwipe: onHorizontalSwipe, - onLongPress: onLongPress, - onPinch: onPinch, - onTap: onTap, - onDoubleTouch: onDoubleTouch, - onVerticalPan: onVerticalPan, - longPressMinimumDuration: longPressMinimumDuration, - samePointPadding: samePointPadding, - samePointTimeout: samePointTimeout, - swipeTranslation: swipeTranslation, - swipeVelocity: swipeVelocity, - sameSwipeDirectionTimeout: sameSwipeDirectionTimeout - ) - } - - func updateUIView(_ uiView: UIGestureView, context: Context) {} -} - -extension GestureView { - - init() { - self.init( - longPressMinimumDuration: 0, - samePointPadding: 0, - samePointTimeout: 0, - swipeTranslation: 0, - swipeVelocity: 0, - sameSwipeDirectionTimeout: 0 - ) - } - - func onHorizontalPan(_ action: @escaping PanGestureHandler) -> Self { - copy(modifying: \.onHorizontalPan, with: action) - } - - func onHorizontalSwipe( - translation: CGFloat, - velocity: CGFloat, - sameSwipeDirectionTimeout: TimeInterval = 0, - _ action: @escaping SwipeGestureHandler - ) -> Self { - copy(modifying: \.swipeTranslation, with: translation) - .copy(modifying: \.swipeVelocity, with: velocity) - .copy(modifying: \.sameSwipeDirectionTimeout, with: sameSwipeDirectionTimeout) - .copy(modifying: \.onHorizontalSwipe, with: action) - } - - func onPinch(_ action: @escaping PinchGestureHandler) -> Self { - copy(modifying: \.onPinch, with: action) - } - - func onTap( - samePointPadding: CGFloat, - samePointTimeout: TimeInterval, - _ action: @escaping TapGestureHandler - ) -> Self { - copy(modifying: \.samePointPadding, with: samePointPadding) - .copy(modifying: \.samePointTimeout, with: samePointTimeout) - .copy(modifying: \.onTap, with: action) - } - - func onDoubleTouch(_ action: @escaping TapGestureHandler) -> Self { - copy(modifying: \.onDoubleTouch, with: action) - } - - func onLongPress(minimumDuration: TimeInterval, _ action: @escaping (UnitPoint) -> Void) -> Self { - copy(modifying: \.longPressMinimumDuration, with: minimumDuration) - .copy(modifying: \.onLongPress, with: action) - } - - func onVerticalPan(_ action: @escaping PanGestureHandler) -> Self { - copy(modifying: \.onVerticalPan, with: action) - } -} - -class UIGestureView: UIView { - - private let onHorizontalPan: PanGestureHandler? - private let onHorizontalSwipe: SwipeGestureHandler? - private let onLongPress: ((UnitPoint) -> Void)? - private let onPinch: PinchGestureHandler? - private let onTap: TapGestureHandler? - private let onDoubleTouch: TapGestureHandler? - private let onVerticalPan: PanGestureHandler? - - private let longPressMinimumDuration: TimeInterval - private let samePointPadding: CGFloat - private let samePointTimeout: TimeInterval - private let swipeTranslation: CGFloat - private let swipeVelocity: CGFloat - private var sameSwipeDirectionTimeout: TimeInterval - - private var hasSwiped: Bool = false - private var lastSwipeDirection: Bool? - private var lastTouchLocation: CGPoint? - private var multiTapWorkItem: DispatchWorkItem? - private var sameSwipeWorkItem: DispatchWorkItem? - private var multiTapAmount: Int = 0 - private var sameSwipeAmount: Int = 0 - - init( - onHorizontalPan: PanGestureHandler?, - onHorizontalSwipe: SwipeGestureHandler?, - onLongPress: ((UnitPoint) -> Void)?, - onPinch: PinchGestureHandler?, - onTap: TapGestureHandler?, - onDoubleTouch: TapGestureHandler?, - onVerticalPan: PanGestureHandler?, - longPressMinimumDuration: TimeInterval, - samePointPadding: CGFloat, - samePointTimeout: TimeInterval, - swipeTranslation: CGFloat, - swipeVelocity: CGFloat, - sameSwipeDirectionTimeout: TimeInterval - ) { - self.onHorizontalPan = onHorizontalPan - self.onHorizontalSwipe = onHorizontalSwipe - self.onLongPress = onLongPress - self.onPinch = onPinch - self.onTap = onTap - self.onDoubleTouch = onDoubleTouch - self.onVerticalPan = onVerticalPan - self.longPressMinimumDuration = longPressMinimumDuration - self.samePointPadding = samePointPadding - self.samePointTimeout = samePointTimeout - self.swipeTranslation = swipeTranslation - self.swipeVelocity = swipeVelocity - self.sameSwipeDirectionTimeout = sameSwipeDirectionTimeout - super.init(frame: .zero) - - let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPerformPinch)) - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didPerformTap)) - let doubleTouchGesture = UITapGestureRecognizer(target: self, action: #selector(didPerformDoubleTouch)) - doubleTouchGesture.numberOfTouchesRequired = 2 - let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didPerformLongPress)) - longPressGesture.minimumPressDuration = longPressMinimumDuration - let verticalPanGesture = PanDirectionGestureRecognizer( - direction: .vertical, - target: self, - action: #selector(didPerformVerticalPan) - ) - let horizontalPanGesture = PanDirectionGestureRecognizer( - direction: .horizontal, - target: self, - action: #selector(didPerformHorizontalPan) - ) - - addGestureRecognizer(pinchGesture) - addGestureRecognizer(tapGesture) - addGestureRecognizer(doubleTouchGesture) - addGestureRecognizer(longPressGesture) - addGestureRecognizer(verticalPanGesture) - addGestureRecognizer(horizontalPanGesture) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @objc - private func didPerformHorizontalPan(_ gestureRecognizer: PanDirectionGestureRecognizer) { - let translation = gestureRecognizer.translation(in: self).x - let unitPoint = gestureRecognizer.unitPoint(in: self) - let velocity = gestureRecognizer.velocity(in: self).x - - onHorizontalPan?(gestureRecognizer.state, unitPoint, velocity, translation) - - if !hasSwiped, - abs(translation) >= swipeTranslation, - abs(velocity) >= swipeVelocity - { - didPerformSwipe(unitPoint: unitPoint, direction: translation > 0) - - hasSwiped = true - } - - if gestureRecognizer.state == .ended { - hasSwiped = false - } - } - - private func didPerformSwipe(unitPoint: UnitPoint, direction: Bool) { - - if lastSwipeDirection == direction { - sameSwipeOccurred(unitPoint: unitPoint, direction: direction) - onHorizontalSwipe?(unitPoint, direction, sameSwipeAmount) - } else { - sameSwipeOccurred(unitPoint: unitPoint, direction: direction) - onHorizontalSwipe?(unitPoint, direction, 1) - } - } - - private func sameSwipeOccurred(unitPoint: UnitPoint, direction: Bool) { - guard sameSwipeDirectionTimeout > 0 else { return } - lastSwipeDirection = direction - - sameSwipeAmount += 1 - - sameSwipeWorkItem?.cancel() - let task = DispatchWorkItem { - self.sameSwipeAmount = 0 - self.lastSwipeDirection = nil - } - - sameSwipeWorkItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + sameSwipeDirectionTimeout, execute: task) - } - - @objc - private func didPerformLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) { - guard let onLongPress, gestureRecognizer.state == .began else { return } - let unitPoint = gestureRecognizer.unitPoint(in: self) - - onLongPress(unitPoint) - } - - @objc - private func didPerformPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { - guard let onPinch else { return } - let unitPoint = gestureRecognizer.unitPoint(in: self) - - onPinch(gestureRecognizer.state, unitPoint, gestureRecognizer.scale) - } - - @objc - private func didPerformTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard let onTap else { return } - let location = gestureRecognizer.location(in: self) - let unitPoint = gestureRecognizer.unitPoint(in: self) - - if let lastTouchLocation, lastTouchLocation.isNear(lastTouchLocation, padding: samePointPadding) { - multiTapOccurred(at: location) - onTap(unitPoint, multiTapAmount) - } else { - multiTapOccurred(at: location) - onTap(unitPoint, 1) - } - } - - @objc - private func didPerformDoubleTouch(_ gestureRecognizer: UITapGestureRecognizer) { - guard let onDoubleTouch else { return } - let unitPoint = gestureRecognizer.unitPoint(in: self) - - onDoubleTouch(unitPoint, 1) - } - - @objc - private func didPerformVerticalPan(_ gestureRecognizer: PanDirectionGestureRecognizer) { - guard let onVerticalPan else { return } - let translation = gestureRecognizer.translation(in: self).y - let unitPoint = gestureRecognizer.unitPoint(in: self) - let velocity = gestureRecognizer.velocity(in: self).y - - onVerticalPan(gestureRecognizer.state, unitPoint, velocity, translation) - } - - private func multiTapOccurred(at location: CGPoint) { - guard samePointTimeout > 0 else { return } - lastTouchLocation = location - - multiTapAmount += 1 - - multiTapWorkItem?.cancel() - let task = DispatchWorkItem { - self.multiTapAmount = 0 - self.lastTouchLocation = nil - } - - multiTapWorkItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + samePointTimeout, execute: task) - } -} diff --git a/jellypig iOS/Components/HourMinutePicker.swift b/jellypig iOS/Components/HourMinutePicker.swift deleted file mode 100644 index 1ad1b7b6..00000000 --- a/jellypig iOS/Components/HourMinutePicker.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct HourMinutePicker: UIViewRepresentable { - - let interval: Binding - - func makeUIView(context: Context) -> some UIView { - let picker = UIDatePicker(frame: .zero) - picker.translatesAutoresizingMaskIntoConstraints = false - picker.datePickerMode = .countDownTimer - picker.countDownDuration = interval.wrappedValue - - context.coordinator.add(picker: picker) - context.coordinator.interval = interval - - return picker - } - - func updateUIView(_ uiView: UIViewType, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - class Coordinator { - - var interval: Binding! - - func add(picker: UIDatePicker) { - picker.addTarget( - self, - action: #selector( - dateChanged - ), - for: .valueChanged - ) - } - - @objc - func dateChanged(_ picker: UIDatePicker) { - interval.wrappedValue = picker.countDownDuration - } - } -} diff --git a/jellypig iOS/Components/LandscapePosterProgressBar.swift b/jellypig iOS/Components/LandscapePosterProgressBar.swift deleted file mode 100644 index e5bd4f12..00000000 --- a/jellypig iOS/Components/LandscapePosterProgressBar.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// TODO: fix relative padding, or remove? -// TODO: gradient should grow/shrink with content, not relative to container - -struct LandscapePosterProgressBar: View { - - @Default(.accentColor) - private var accentColor - - // Scale padding depending on view width - @State - private var paddingScale: CGFloat = 1.0 - @State - private var width: CGFloat = 0 - - private let content: () -> Content - private let progress: Double - - var body: some View { - ZStack(alignment: .bottom) { - - Color.clear - - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black.opacity(0.7), location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 40) - - VStack(alignment: .leading, spacing: 3 * paddingScale) { - - content() - - ProgressBar(progress: progress) - .foregroundColor(accentColor) - .frame(height: 3) - } - .padding(.horizontal, 5 * paddingScale) - .padding(.bottom, 7 * paddingScale) - } - .onSizeChanged { newSize in - width = newSize.width - } - } -} - -extension LandscapePosterProgressBar where Content == Text { - - init( - title: String, - progress: Double - ) { - self.init( - content: { - Text(title) - .font(.subheadline) - .foregroundColor(.white) - }, - progress: progress - ) - } -} - -extension LandscapePosterProgressBar where Content == EmptyView { - - init(progress: Double) { - self.init( - content: { EmptyView() }, - progress: progress - ) - } -} - -extension LandscapePosterProgressBar { - - init( - progress: Double, - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - content: content, - progress: progress - ) - } -} diff --git a/jellypig iOS/Components/LanguagePicker.swift b/jellypig iOS/Components/LanguagePicker.swift deleted file mode 100644 index e4881621..00000000 --- a/jellypig iOS/Components/LanguagePicker.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct LanguagePicker: View { - let title: String - @Binding - var selectedLanguageCode: String? - - // MARK: - Get all localized languages - - private var languages: [(code: String?, name: String)] { - var uniqueLanguages = Set() - - var languageList: [(code: String?, name: String)] = Locale.availableIdentifiers.compactMap { identifier in - let locale = Locale(identifier: identifier) - if let code = locale.languageCode, - let name = locale.localizedString(forLanguageCode: code), - !uniqueLanguages.contains(code) - { - uniqueLanguages.insert(code) - return (code, name) - } - return nil - } - .sorted { $0.name < $1.name } - - // Add None as an option at the top of the list - languageList.insert((code: nil, name: L10n.none), at: 0) - return languageList - } - - // MARK: - Body - - var body: some View { - Picker(title, selection: $selectedLanguageCode) { - ForEach(languages, id: \.code) { language in - Text(language.name).tag(language.code) - } - } - } -} diff --git a/jellypig iOS/Components/LearnMoreButton.swift b/jellypig iOS/Components/LearnMoreButton.swift deleted file mode 100644 index 0f6e4060..00000000 --- a/jellypig iOS/Components/LearnMoreButton.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct LearnMoreButton: View { - - @State - private var isPresented: Bool = false - - private let title: String - private let items: [TextPair] - - // MARK: - Initializer - - init(_ title: String, @ArrayBuilder items: () -> [TextPair]) { - self.title = title - self.items = items() - } - - // MARK: - Body - - var body: some View { - Button(L10n.learnMore + "\u{2026}") { - isPresented = true - } - .foregroundStyle(Color.accentColor) - .buttonStyle(.plain) - .frame(maxWidth: .infinity, alignment: .leading) - .sheet(isPresented: $isPresented) { - learnMoreView - } - } - - // MARK: - Learn More View - - private var learnMoreView: some View { - NavigationView { - ScrollView { - SeparatorVStack(alignment: .leading) { - Divider() - } content: { - ForEach(items) { content in - VStack(alignment: .leading, spacing: 8) { - Text(content.title) - .font(.headline) - .foregroundStyle(.primary) - - Text(content.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding(.vertical, 16) - } - } - .edgePadding(.horizontal) - } - .navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - isPresented = false - } - } - .foregroundStyle(Color.primary, Color.secondary) - } -} diff --git a/jellypig iOS/Components/LetterPickerBar/Components/LetterPickerButton.swift b/jellypig iOS/Components/LetterPickerBar/Components/LetterPickerButton.swift deleted file mode 100644 index 7aff431a..00000000 --- a/jellypig iOS/Components/LetterPickerBar/Components/LetterPickerButton.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension LetterPickerBar { - - struct LetterPickerButton: View { - - @Default(.accentColor) - private var accentColor - - @Environment(\.isSelected) - private var isSelected - - private let letter: ItemLetter - private let size: CGFloat - private let viewModel: FilterViewModel - - init(letter: ItemLetter, size: CGFloat, viewModel: FilterViewModel) { - self.letter = letter - self.size = size - self.viewModel = viewModel - } - - var body: some View { - Button { - if viewModel.currentFilters.letter.contains(letter) { - viewModel.send(.update(.letter, [])) - } else { - viewModel.send(.update(.letter, [ItemLetter(stringLiteral: letter.value).asAnyItemFilter])) - } - } label: { - ZStack { - RoundedRectangle(cornerRadius: 5) - .frame(width: size, height: size) - .foregroundStyle(isSelected ? accentColor : Color.clear) - - Text(letter.value) - .font(.headline) - .foregroundStyle(isSelected ? accentColor.overlayColor : accentColor) - .frame(width: size, height: size, alignment: .center) - } - } - } - } -} diff --git a/jellypig iOS/Components/LetterPickerBar/LetterPickerBar.swift b/jellypig iOS/Components/LetterPickerBar/LetterPickerBar.swift deleted file mode 100644 index f9275cb1..00000000 --- a/jellypig iOS/Components/LetterPickerBar/LetterPickerBar.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct LetterPickerBar: View { - - @ObservedObject - var viewModel: FilterViewModel - - // MARK: - Body - - @ViewBuilder - var body: some View { - VStack(spacing: 0) { - Spacer() - - ForEach(ItemLetter.allCases, id: \.hashValue) { filterLetter in - LetterPickerButton( - letter: filterLetter, - size: LetterPickerBar.size, - viewModel: viewModel - ) - .environment(\.isSelected, viewModel.currentFilters.letter.contains(filterLetter)) - } - - Spacer() - } - .scrollIfLargerThanContainer() - .frame(width: LetterPickerBar.size, alignment: .center) - } - - // MARK: - Letter Button Size - - static var size: CGFloat { - String().height( - withConstrainedWidth: CGFloat.greatestFiniteMagnitude, - font: UIFont.preferredFont( - forTextStyle: .headline - ) - ) - } -} diff --git a/jellypig iOS/Components/ListRow.swift b/jellypig iOS/Components/ListRow.swift deleted file mode 100644 index 260b8180..00000000 --- a/jellypig iOS/Components/ListRow.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: come up with better name along with `ListRowButton` - -// Meant to be used when making a custom list without `List` or `Form` -struct ListRow: View { - - @State - private var contentSize: CGSize = .zero - - private let leading: () -> Leading - private let content: () -> Content - private var action: () -> Void - private var insets: EdgeInsets - private var isSeparatorVisible: Bool - - var body: some View { - ZStack(alignment: .bottomTrailing) { - - Button { - action() - } label: { - HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { - - leading() - - content() - .frame(maxHeight: .infinity) - .trackingSize($contentSize) - } - .padding(.top, insets.top) - .padding(.bottom, insets.bottom) - .padding(.leading, insets.leading) - .padding(.trailing, insets.trailing) - } - .foregroundStyle(.primary, .secondary) - - Color.secondarySystemFill - .frame(width: contentSize.width, height: 1) - .padding(.trailing, insets.trailing) - .visible(isSeparatorVisible) - } - } -} - -extension ListRow { - - init( - insets: EdgeInsets = .zero, - @ViewBuilder leading: @escaping () -> Leading, - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - leading: leading, - content: content, - action: {}, - insets: insets, - isSeparatorVisible: true - ) - } - - func isSeparatorVisible(_ isVisible: Bool) -> Self { - copy(modifying: \.isSeparatorVisible, with: isVisible) - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.action, with: action) - } -} diff --git a/jellypig iOS/Components/ListRowButton.swift b/jellypig iOS/Components/ListRowButton.swift deleted file mode 100644 index 544742ea..00000000 --- a/jellypig iOS/Components/ListRowButton.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: come up with better name along with `ListRow` - -// Meant to be used within `List` or `Form` -struct ListRowButton: View { - - private let title: String - private let role: ButtonRole? - private let action: () -> Void - - init(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) { - self.title = title - self.role = role - self.action = action - } - - var body: some View { - Button(title, role: role, action: action) - .buttonStyle(ListRowButtonStyle()) - .listRowInsets(.zero) - } -} - -private struct ListRowButtonStyle: ButtonStyle { - - @Environment(\.isEnabled) - private var isEnabled - - private func primaryStyle(configuration: Configuration) -> some ShapeStyle { - if configuration.role == .destructive || configuration.role == .cancel { - return AnyShapeStyle(Color.red) - } else { - return AnyShapeStyle(HierarchicalShapeStyle.primary) - } - } - - private func secondaryStyle(configuration: Configuration) -> some ShapeStyle { - if configuration.role == .destructive || configuration.role == .cancel { - return AnyShapeStyle(Color.red.opacity(0.2)) - } else { - return isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray) - } - } - - func makeBody(configuration: Configuration) -> some View { - ZStack { - Rectangle() - .fill(secondaryStyle(configuration: configuration)) - - configuration.label - .foregroundStyle(primaryStyle(configuration: configuration)) - } - .opacity(configuration.isPressed ? 0.75 : 1) - .font(.body.weight(.bold)) - } -} diff --git a/jellypig iOS/Components/ListTitleSection.swift b/jellypig iOS/Components/ListTitleSection.swift deleted file mode 100644 index 8fae6c08..00000000 --- a/jellypig iOS/Components/ListTitleSection.swift +++ /dev/null @@ -1,192 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// TODO: image -// TODO: rename - -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) - .multilineTextAlignment(.center) - - if let description { - Text(description) - .multilineTextAlignment(.center) - } - - if let onLearnMore { - Button(L10n.learnMore + "\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 - ) - } -} - -/// A view that mimics an inset grouped section, meant to be -/// used as a header for a `List` with `listStyle(.plain)`. -struct InsetGroupedListHeader: View { - - @Default(.accentColor) - private var accentColor - - private let content: () -> Content - private let title: Text? - private let description: Text? - private let onLearnMore: (() -> Void)? - - @ViewBuilder - private var header: some View { - Button { - onLearnMore?() - } label: { - VStack(alignment: .center, spacing: 10) { - - if let title { - title - .font(.title3) - .fontWeight(.semibold) - } - - if let description { - description - .multilineTextAlignment(.center) - } - - if onLearnMore != nil { - Text(L10n.learnMore + "\u{2026}") - .foregroundStyle(accentColor) - } - } - .font(.subheadline) - .frame(maxWidth: .infinity) - .padding(16) - } - .foregroundStyle(.primary, .secondary) - } - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 16) - .fill(Color.secondarySystemBackground) - - SeparatorVStack { - RowDivider() - } content: { - if title != nil || description != nil { - header - } - - content() - .listRowSeparator(.hidden) - .padding(.init(vertical: 5, horizontal: 20)) - .listRowInsets(.init(vertical: 10, horizontal: 20)) - } - } - } -} - -extension InsetGroupedListHeader { - - init( - _ title: String? = nil, - description: String? = nil, - onLearnMore: (() -> Void)? = nil, - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - content: content, - title: title == nil ? nil : Text(title!), - description: description == nil ? nil : Text(description!), - onLearnMore: onLearnMore - ) - } - - init( - title: Text, - description: Text? = nil, - onLearnMore: (() -> Void)? = nil, - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - content: content, - title: title, - description: description, - onLearnMore: onLearnMore - ) - } -} - -extension InsetGroupedListHeader where Content == EmptyView { - - init( - _ title: String, - description: String? = nil, - onLearnMore: (() -> Void)? = nil - ) { - self.init( - content: { EmptyView() }, - title: Text(title), - description: description == nil ? nil : Text(description!), - onLearnMore: onLearnMore - ) - } - - init( - title: Text, - description: Text? = nil, - onLearnMore: (() -> Void)? = nil - ) { - self.init( - content: { EmptyView() }, - title: title, - description: description, - onLearnMore: onLearnMore - ) - } -} diff --git a/jellypig iOS/Components/NavigationBarFilterDrawer/FilterDrawerButton.swift b/jellypig iOS/Components/NavigationBarFilterDrawer/FilterDrawerButton.swift deleted file mode 100644 index 97fcf6c6..00000000 --- a/jellypig iOS/Components/NavigationBarFilterDrawer/FilterDrawerButton.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension NavigationBarFilterDrawer { - - struct FilterDrawerButton: View { - - @Default(.accentColor) - private var accentColor - - @Environment(\.isSelected) - private var isSelected - - private let systemName: String? - private let title: String - private var onSelect: () -> Void - - var body: some View { - Button { - onSelect() - } label: { - HStack(spacing: 2) { - Group { - if let systemName = systemName { - Image(systemName: systemName) - } else { - Text(title) - } - } - .font(.footnote.weight(.semibold)) - - Image(systemName: "chevron.down") - .font(.caption) - } - .foregroundColor(.primary) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background { - Capsule() - .foregroundColor(isSelected ? accentColor : Color(UIColor.secondarySystemFill)) - .opacity(0.5) - } - .overlay { - Capsule() - .stroke(isSelected ? accentColor : Color(UIColor.secondarySystemFill), lineWidth: 1) - } - } - } - } -} - -extension NavigationBarFilterDrawer.FilterDrawerButton { - - init(title: String) { - self.init( - systemName: nil, - title: title, - onSelect: {} - ) - } - - init(systemName: String) { - self.init( - systemName: systemName, - title: "", - onSelect: {} - ) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Components/NavigationBarFilterDrawer/NavigationBarFilterDrawer.swift b/jellypig iOS/Components/NavigationBarFilterDrawer/NavigationBarFilterDrawer.swift deleted file mode 100644 index efcfe3e1..00000000 --- a/jellypig iOS/Components/NavigationBarFilterDrawer/NavigationBarFilterDrawer.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct NavigationBarFilterDrawer: View { - - @ObservedObject - private var viewModel: FilterViewModel - - private var filterTypes: [ItemFilterType] - private var onSelect: (FilterCoordinator.Parameters) -> Void - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - if viewModel.currentFilters.hasFilters { - Menu { - Button(L10n.reset, role: .destructive) { - viewModel.send(.reset()) - } - } label: { - FilterDrawerButton(systemName: "line.3.horizontal.decrease.circle.fill") - .environment(\.isSelected, true) - } - } - - ForEach(filterTypes, id: \.self) { type in - FilterDrawerButton( - title: type.displayTitle - ) - .onSelect { - onSelect(.init(type: type, viewModel: viewModel)) - } - .environment( - \.isSelected, - viewModel.isFilterSelected(type: type) - ) - } - } - .padding(.horizontal) - .padding(.vertical, 1) - } - } -} - -extension NavigationBarFilterDrawer { - - init(viewModel: FilterViewModel, types: [ItemFilterType]) { - self.init( - viewModel: viewModel, - filterTypes: types, - onSelect: { _ in } - ) - } - - func onSelect(_ action: @escaping (FilterCoordinator.Parameters) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Components/OrderedSectionSelectorView.swift b/jellypig iOS/Components/OrderedSectionSelectorView.swift deleted file mode 100644 index 6f0b4577..00000000 --- a/jellypig iOS/Components/OrderedSectionSelectorView.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct OrderedSectionSelectorView: View { - - @Environment(\.editMode) - private var editMode - - @StateObject - private var selection: BindingBox<[Element]> - - private var disabledSelection: [Element] { - sources.subtracting(selection.value) - } - - private var label: (Element) -> any View - private let sources: [Element] - - private func move(from source: IndexSet, to destination: Int) { - selection.value.move(fromOffsets: source, toOffset: destination) - } - - private func select(element: Element) { - - UIDevice.impact(.light) - - if selection.value.contains(element) { - selection.value.removeAll(where: { $0 == element }) - } else { - selection.value.append(element) - } - } - - private var isReordering: Bool { - editMode?.wrappedValue.isEditing ?? false - } - - var body: some View { - List { - Section(L10n.enabled) { - - if selection.value.isEmpty { - L10n.none.text - .foregroundStyle(.secondary) - } - - ForEach(selection.value, id: \.self) { element in - Button { - if !isReordering { - select(element: element) - } - } label: { - HStack { - label(element) - .eraseToAnyView() - - Spacer() - - if !isReordering { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - } - .foregroundColor(.primary) - } - } - .onMove(perform: move) - } - - Section(L10n.disabled) { - - if disabledSelection.isEmpty { - L10n.none.text - .foregroundStyle(.secondary) - } - - ForEach(disabledSelection, id: \.self) { element in - Button { - if !isReordering { - select(element: element) - } - } label: { - HStack { - label(element) - .eraseToAnyView() - - Spacer() - - if !isReordering { - Image(systemName: "plus.circle.fill") - .foregroundColor(.green) - } - } - .foregroundColor(.primary) - } - } - } - } - .animation(.linear(duration: 0.2), value: selection.value) - .toolbar { - EditButton() - } - } -} - -extension OrderedSectionSelectorView { - - init(selection: Binding<[Element]>, sources: [Element]) { - self._selection = StateObject(wrappedValue: BindingBox(source: selection)) - self.sources = sources - self.label = { Text($0.displayTitle).foregroundColor(.primary) } - } - - func label(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self { - copy(modifying: \.label, with: content) - } -} diff --git a/jellypig iOS/Components/PillHStack.swift b/jellypig iOS/Components/PillHStack.swift deleted file mode 100644 index ce8707a7..00000000 --- a/jellypig iOS/Components/PillHStack.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct PillHStack: View { - - private var title: String - private var items: [Item] - private var onSelect: (Item) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) - .edgePadding(.leading) - - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(items, id: \.displayTitle) { item in - Button { - onSelect(item) - } label: { - Text(item.displayTitle) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .padding(10) - .background { - Color.systemFill - .cornerRadius(10) - } - } - } - } - .edgePadding(.horizontal) - } - } - } -} - -extension PillHStack { - - init(title: String, items: [Item]) { - self.init( - title: title, - items: items, - onSelect: { _ in } - ) - } - - func onSelect(_ action: @escaping (Item) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Components/PosterButton.swift b/jellypig iOS/Components/PosterButton.swift deleted file mode 100644 index 9010f649..00000000 --- a/jellypig iOS/Components/PosterButton.swift +++ /dev/null @@ -1,266 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -// TODO: expose `ImageView.image` modifier for image aspect fill/fit -// TODO: allow `content` to trigger `onSelect`? -// - not in button label to avoid context menu visual oddities -// TODO: why don't shadows work with failure image views? -// - due to `Color`? - -/// Retrieving images by exact pixel dimensions is a bit -/// intense for normal usage and eases cache usage and modifications. -private let landscapeMaxWidth: CGFloat = 200 -private let portraitMaxWidth: CGFloat = 200 - -struct PosterButton: View { - - private var item: Item - private var type: PosterDisplayType - private var content: () -> any View - private var imageOverlay: () -> any View - private var contextMenu: () -> any View - private var onSelect: () -> Void - - private func imageView(from item: Item) -> ImageView { - switch type { - case .landscape: - ImageView(item.landscapeImageSources(maxWidth: landscapeMaxWidth)) - case .portrait: - ImageView(item.portraitImageSources(maxWidth: portraitMaxWidth)) - } - } - - var body: some View { - VStack(alignment: .leading) { - Button { - onSelect() - } label: { - ZStack { - Color.clear - - imageView(from: item) - .failure { - if item.showTitle { - SystemImageContentView(systemName: item.systemImage) - } else { - SystemImageContentView( - title: item.displayTitle, - systemName: item.systemImage - ) - } - } - - imageOverlay() - .eraseToAnyView() - } - .posterStyle(type) - } - .buttonStyle(.plain) - .contextMenu(menuItems: { - contextMenu() - .eraseToAnyView() - }) - .posterShadow() - - content() - .eraseToAnyView() - } - } -} - -extension PosterButton { - - init( - item: Item, - type: PosterDisplayType - ) { - self.init( - item: item, - type: type, - content: { TitleSubtitleContentView(item: item) }, - imageOverlay: { DefaultOverlay(item: item) }, - contextMenu: { EmptyView() }, - onSelect: {} - ) - } - - func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.content, with: content) - } - - func imageOverlay(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.imageOverlay, with: content) - } - - func contextMenu(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.contextMenu, with: content) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} - -// TODO: Shared default content with tvOS? -// - check if content is generally same - -extension PosterButton { - - // MARK: Default Content - - struct TitleContentView: View { - - let item: Item - - var body: some View { - Text(item.displayTitle) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - } - } - - struct SubtitleContentView: View { - - let item: Item - - var body: some View { - Text(item.subtitle ?? " ") - .font(.caption.weight(.medium)) - .foregroundColor(.secondary) - } - } - - struct TitleSubtitleContentView: View { - - let item: Item - - var body: some View { - iOS15View { - VStack(alignment: .leading, spacing: 0) { - if item.showTitle { - TitleContentView(item: item) - .backport - .lineLimit(1, reservesSpace: true) - .iOS15 { v in - v.font(.footnote.weight(.regular)) - } - } - - SubtitleContentView(item: item) - .backport - .lineLimit(1, reservesSpace: true) - .iOS15 { v in - v.font(.caption.weight(.medium)) - } - } - } content: { - VStack(alignment: .leading) { - if item.showTitle { - TitleContentView(item: item) - .backport - .lineLimit(1, reservesSpace: true) - } - - SubtitleContentView(item: item) - .backport - .lineLimit(1, reservesSpace: true) - } - } - } - } - - // Content specific for BaseItemDto episode items - struct EpisodeContentSubtitleContent: View { - - @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) - private var useSeriesLandscapeBackdrop - - let item: Item - - var body: some View { - if let item = item as? BaseItemDto { - // Unsure why this needs 0 spacing - // compared to other default content - VStack(alignment: .leading, spacing: 0) { - if item.showTitle, let seriesName = item.seriesName { - Text(seriesName) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - .backport - .lineLimit(1, reservesSpace: true) - } - - SeparatorHStack { - Text(item.seasonEpisodeLabel ?? .emptyDash) - - if item.showTitle || useSeriesLandscapeBackdrop { - Text(item.displayTitle) - } else if let seriesName = item.seriesName { - Text(seriesName) - } - } - .separator { - Circle() - .frame(width: 2, height: 2) - .padding(.horizontal, 3) - } - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - } - - // MARK: Default Overlay - - struct DefaultOverlay: View { - - @Default(.accentColor) - private var accentColor - @Default(.Customization.Indicators.showFavorited) - private var showFavorited - @Default(.Customization.Indicators.showProgress) - private var showProgress - @Default(.Customization.Indicators.showUnplayed) - private var showUnplayed - @Default(.Customization.Indicators.showPlayed) - private var showPlayed - - let item: Item - - var body: some View { - ZStack { - if let item = item as? BaseItemDto { - if item.userData?.isPlayed ?? false { - WatchedIndicator(size: 25) - .visible(showPlayed) - } else { - if (item.userData?.playbackPositionTicks ?? 0) > 0 { - ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 5) - .visible(showProgress) - } else { - UnwatchedIndicator(size: 25) - .foregroundColor(accentColor) - .visible(showUnplayed) - } - } - - if item.userData?.isFavorite ?? false { - FavoriteIndicator(size: 25) - .visible(showFavorited) - } - } - } - } - } -} diff --git a/jellypig iOS/Components/PosterHStack.swift b/jellypig iOS/Components/PosterHStack.swift deleted file mode 100644 index e0fd6d7b..00000000 --- a/jellypig iOS/Components/PosterHStack.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionHStack -import OrderedCollections -import SwiftUI - -struct PosterHStack: View where Data.Element == Element, Data.Index == Int { - - private var data: Data - private var header: () -> any View - private var title: String? - private var type: PosterDisplayType - private var content: (Element) -> any View - private var imageOverlay: (Element) -> any View - private var contextMenu: (Element) -> any View - private var trailingContent: () -> any View - private var onSelect: (Element) -> Void - - @ViewBuilder - private var padHStack: some View { - CollectionHStack( - uniqueElements: data, - minWidth: type == .portrait ? 140 : 220 - ) { item in - PosterButton( - item: item, - type: type - ) - .content { content(item).eraseToAnyView() } - .imageOverlay { imageOverlay(item).eraseToAnyView() } - .contextMenu { contextMenu(item).eraseToAnyView() } - .onSelect { onSelect(item) } - } - .clipsToBounds(false) - .dataPrefix(20) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollBehavior(.continuousLeadingEdge) - } - - @ViewBuilder - private var phoneHStack: some View { - CollectionHStack( - uniqueElements: data, - columns: type == .portrait ? 3 : 2 - ) { item in - PosterButton( - item: item, - type: type - ) - .content { content(item).eraseToAnyView() } - .imageOverlay { imageOverlay(item).eraseToAnyView() } - .contextMenu { contextMenu(item).eraseToAnyView() } - .onSelect { onSelect(item) } - } - .clipsToBounds(false) - .dataPrefix(20) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollBehavior(.continuousLeadingEdge) - } - - var body: some View { - VStack(alignment: .leading) { - - HStack { - header() - .eraseToAnyView() - - Spacer() - - trailingContent() - .eraseToAnyView() - } - .edgePadding(.horizontal) - - if UIDevice.isPhone { - phoneHStack - } else { - padHStack - } - } - } -} - -extension PosterHStack { - - init( - title: String? = nil, - type: PosterDisplayType, - items: Data - ) { - self.init( - data: items, - header: { DefaultHeader(title: title) }, - title: title, - type: type, - content: { PosterButton.TitleSubtitleContentView(item: $0) }, - imageOverlay: { PosterButton.DefaultOverlay(item: $0) }, - contextMenu: { _ in EmptyView() }, - trailingContent: { EmptyView() }, - onSelect: { _ in } - ) - } - - func header(@ViewBuilder _ header: @escaping () -> any View) -> Self { - copy(modifying: \.header, with: header) - } - - func content(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self { - copy(modifying: \.content, with: content) - } - - func imageOverlay(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self { - copy(modifying: \.imageOverlay, with: content) - } - - func contextMenu(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self { - copy(modifying: \.contextMenu, with: content) - } - - func trailing(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trailingContent, with: content) - } - - func onSelect(_ action: @escaping (Element) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} - -// MARK: Default Header - -extension PosterHStack { - - struct DefaultHeader: View { - - let title: String? - - var body: some View { - if let title { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) - } - } - } -} diff --git a/jellypig iOS/Components/PrimaryButton.swift b/jellypig iOS/Components/PrimaryButton.swift deleted file mode 100644 index 87cee22a..00000000 --- a/jellypig iOS/Components/PrimaryButton.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct PrimaryButton: View { - - @Default(.accentColor) - private var accentColor - - private let title: String - private var onSelect: () -> Void - - var body: some View { - Button { - onSelect() - } label: { - ZStack { - Rectangle() - .foregroundColor(accentColor) - .frame(maxWidth: 400) - .frame(height: 50) - .cornerRadius(10) - - Text(title) - .fontWeight(.bold) - .foregroundColor(accentColor.overlayColor) - } - } - } -} - -extension PrimaryButton { - - init(title: String) { - self.init( - title: title, - onSelect: {} - ) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Components/SeeAllButton.swift b/jellypig iOS/Components/SeeAllButton.swift deleted file mode 100644 index d50f94b4..00000000 --- a/jellypig iOS/Components/SeeAllButton.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct SeeAllButton: View { - - private var onSelect: () -> Void - - var body: some View { - Button { - onSelect() - } label: { - HStack { - L10n.seeAll.text - Image(systemName: "chevron.right") - } - .font(.subheadline.bold()) - } - } -} - -extension SeeAllButton { - - init() { - self.init( - onSelect: {} - ) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Components/SettingsBarButton.swift b/jellypig iOS/Components/SettingsBarButton.swift deleted file mode 100644 index 45b81164..00000000 --- a/jellypig iOS/Components/SettingsBarButton.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Factory -import SwiftUI - -struct SettingsBarButton: View { - - let server: ServerState - let user: UserState - let action: () -> Void - - var body: some View { - Button(action: action) { - AlternateLayoutView { - // Seems necessary for button layout - Image(systemName: "gearshape.fill") - } content: { - UserProfileImage( - userID: user.id, - source: user.profileImageSource( - client: server.client, - maxWidth: 120 - ), - pipeline: .Swiftfin.local - ) - } - } - .accessibilityLabel(L10n.settings) - } -} diff --git a/jellypig iOS/Components/Slider/CapsuleSlider.swift b/jellypig iOS/Components/Slider/CapsuleSlider.swift deleted file mode 100644 index 4bfd8062..00000000 --- a/jellypig iOS/Components/Slider/CapsuleSlider.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct CapsuleSlider: View { - - @Default(.VideoPlayer.Overlay.sliderColor) - private var sliderColor - - @Binding - private var isEditing: Bool - @Binding - private var progress: CGFloat - - private var trackMask: () -> any View - private var topContent: () -> any View - private var bottomContent: () -> any View - private var leadingContent: () -> any View - private var trailingContent: () -> any View - - var body: some View { - Slider(progress: $progress) - .gestureBehavior(.track) - .trackGesturePadding(.init(top: 10, leading: 0, bottom: 30, trailing: 0)) - .onEditingChanged { isEditing in - self.isEditing = isEditing - } - .track { - Capsule() - .frame(height: isEditing ? 20 : 10) - .foregroundColor(isEditing ? sliderColor : sliderColor.opacity(0.8)) - } - .trackBackground { - Capsule() - .frame(height: isEditing ? 20 : 10) - .foregroundColor(Color.gray) - .opacity(0.5) - } - .trackMask(trackMask) - .topContent(topContent) - .bottomContent(bottomContent) - .leadingContent(leadingContent) - .trailingContent(trailingContent) - } -} - -extension CapsuleSlider { - - init(progress: Binding) { - self.init( - isEditing: .constant(false), - progress: progress, - trackMask: { Color.white }, - topContent: { EmptyView() }, - bottomContent: { EmptyView() }, - leadingContent: { EmptyView() }, - trailingContent: { EmptyView() } - ) - } - - func isEditing(_ isEditing: Binding) -> Self { - copy(modifying: \._isEditing, with: isEditing) - } - - func trackMask(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trackMask, with: content) - } - - func topContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.topContent, with: content) - } - - func bottomContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.bottomContent, with: content) - } - - func leadingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.leadingContent, with: content) - } - - func trailingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trailingContent, with: content) - } -} diff --git a/jellypig iOS/Components/Slider/Slider.swift b/jellypig iOS/Components/Slider/Slider.swift deleted file mode 100644 index dd0a956b..00000000 --- a/jellypig iOS/Components/Slider/Slider.swift +++ /dev/null @@ -1,206 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct Slider: View { - - enum Behavior { - case thumb - case track - } - - @Binding - private var progress: CGFloat - - @State - private var isEditing: Bool = false - @State - private var totalWidth: CGFloat = 0 - @State - private var dragStartProgress: CGFloat = 0 - @State - private var currentTranslationStartLocation: CGPoint = .zero - @State - private var currentTranslation: CGFloat = 0 - @State - private var thumbSize: CGSize = .zero - - private var sliderBehavior: Behavior - private var trackGesturePadding: EdgeInsets - private var track: () -> any View - private var trackBackground: () -> any View - private var trackMask: () -> any View - private var thumb: () -> any View - private var topContent: () -> any View - private var bottomContent: () -> any View - private var leadingContent: () -> any View - private var trailingContent: () -> any View - private var onEditingChanged: (Bool) -> Void - private var progressAnimation: Animation - - private var trackDrag: some Gesture { - DragGesture(coordinateSpace: .global) - .onChanged { value in - if !isEditing { - isEditing = true - onEditingChanged(true) - dragStartProgress = progress - currentTranslationStartLocation = value.location - currentTranslation = 0 - } - - currentTranslation = currentTranslationStartLocation.x - value.location.x - - let newProgress: CGFloat = dragStartProgress - currentTranslation / totalWidth - progress = min(max(0, newProgress), 1) - } - .onEnded { _ in - isEditing = false - onEditingChanged(false) - } - } - - var body: some View { - HStack(alignment: .sliderCenterAlignmentGuide, spacing: 0) { - leadingContent() - .eraseToAnyView() - .alignmentGuide(.sliderCenterAlignmentGuide) { context in - context[VerticalAlignment.center] - } - - VStack(spacing: 0) { - topContent() - .eraseToAnyView() - - ZStack(alignment: .leading) { - - ZStack { - trackBackground() - .eraseToAnyView() - - track() - .eraseToAnyView() - .mask(alignment: .leading) { - Color.white - .frame(width: progress * totalWidth) - } - } - .mask { - trackMask() - .eraseToAnyView() - } - - thumb() - .eraseToAnyView() - .if(sliderBehavior == .thumb) { view in - view.gesture(trackDrag) - } - .onSizeChanged { newSize in - thumbSize = newSize - } - .offset(x: progress * totalWidth - thumbSize.width / 2) - } - .onSizeChanged { size in - totalWidth = size.width - } - .if(sliderBehavior == .track) { view in - view.overlay { - Color.clear - .padding(trackGesturePadding) - .contentShape(Rectangle()) - .highPriorityGesture(trackDrag) - } - } - .alignmentGuide(.sliderCenterAlignmentGuide) { context in - context[VerticalAlignment.center] - } - - bottomContent() - .eraseToAnyView() - } - - trailingContent() - .eraseToAnyView() - .alignmentGuide(.sliderCenterAlignmentGuide) { context in - context[VerticalAlignment.center] - } - } - .animation(progressAnimation, value: progress) - .animation(.linear(duration: 0.2), value: isEditing) - } -} - -extension Slider { - - init(progress: Binding) { - self.init( - progress: progress, - sliderBehavior: .track, - trackGesturePadding: .zero, - track: { EmptyView() }, - trackBackground: { EmptyView() }, - trackMask: { EmptyView() }, - thumb: { EmptyView() }, - topContent: { EmptyView() }, - bottomContent: { EmptyView() }, - leadingContent: { EmptyView() }, - trailingContent: { EmptyView() }, - onEditingChanged: { _ in }, - progressAnimation: .linear(duration: 0.05) - ) - } - - func track(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.track, with: content) - } - - func trackBackground(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trackBackground, with: content) - } - - func trackMask(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trackMask, with: content) - } - - func thumb(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.thumb, with: content) - } - - func topContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.topContent, with: content) - } - - func bottomContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.bottomContent, with: content) - } - - func leadingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.leadingContent, with: content) - } - - func trailingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trailingContent, with: content) - } - - func trackGesturePadding(_ insets: EdgeInsets) -> Self { - copy(modifying: \.trackGesturePadding, with: insets) - } - - func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self { - copy(modifying: \.onEditingChanged, with: action) - } - - func gestureBehavior(_ sliderBehavior: Behavior) -> Self { - copy(modifying: \.sliderBehavior, with: sliderBehavior) - } - - func progressAnimation(_ animation: Animation) -> Self { - copy(modifying: \.progressAnimation, with: animation) - } -} diff --git a/jellypig iOS/Components/Slider/ThumbSlider.swift b/jellypig iOS/Components/Slider/ThumbSlider.swift deleted file mode 100644 index 3d573ef9..00000000 --- a/jellypig iOS/Components/Slider/ThumbSlider.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct ThumbSlider: View { - - @Default(.VideoPlayer.Overlay.sliderColor) - private var sliderColor - - @Binding - private var isEditing: Bool - @Binding - private var progress: CGFloat - - private var trackMask: () -> any View - private var topContent: () -> any View - private var bottomContent: () -> any View - private var leadingContent: () -> any View - private var trailingContent: () -> any View - - var body: some View { - Slider(progress: $progress) - .gestureBehavior(.thumb) - .onEditingChanged { isEditing in - self.isEditing = isEditing - } - .track { - Capsule() - .foregroundColor(sliderColor) - .frame(height: 5) - } - .trackBackground { - Capsule() - .foregroundColor(Color.gray) - .opacity(0.5) - .frame(height: 5) - } - .thumb { - ZStack { - Color.clear - .frame(height: 25) - - Circle() - .foregroundColor(sliderColor) - .frame(width: isEditing ? 25 : 20) - } - .overlay { - Color.clear - .frame(width: 50, height: 50) - .contentShape(Rectangle()) - } - } - .trackMask(trackMask) - .topContent(topContent) - .bottomContent(bottomContent) - .leadingContent(leadingContent) - .trailingContent(trailingContent) - } -} - -extension ThumbSlider { - - init(progress: Binding) { - self.init( - isEditing: .constant(false), - progress: progress, - trackMask: { Color.white }, - topContent: { EmptyView() }, - bottomContent: { EmptyView() }, - leadingContent: { EmptyView() }, - trailingContent: { EmptyView() } - ) - } - - func isEditing(_ isEditing: Binding) -> Self { - copy(modifying: \._isEditing, with: isEditing) - } - - func trackMask(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trackMask, with: content) - } - - func topContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.topContent, with: content) - } - - func bottomContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.bottomContent, with: content) - } - - func leadingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.leadingContent, with: content) - } - - func trailingContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trailingContent, with: content) - } -} diff --git a/jellypig iOS/Components/SplitContentView.swift b/jellypig iOS/Components/SplitContentView.swift deleted file mode 100644 index 51c134e9..00000000 --- a/jellypig iOS/Components/SplitContentView.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -class SplitContentViewProxy: ObservableObject { - - @Published - private(set) var isPresentingSplitView: Bool = false - - func present() { - isPresentingSplitView = true - } - - func hide() { - isPresentingSplitView = false - } -} - -struct SplitContentView: View { - - @ObservedObject - private var proxy: SplitContentViewProxy - - private var content: () -> any View - private var splitContent: () -> any View - private var splitContentWidth: CGFloat - - var body: some View { - HStack(spacing: 0) { - - content() - .eraseToAnyView() - .frame(maxWidth: .infinity) - - if proxy.isPresentingSplitView { - splitContent() - .eraseToAnyView() - .transition(.move(edge: .bottom)) - .frame(width: splitContentWidth) - .zIndex(100) - } - } - .animation(.easeInOut(duration: 0.35), value: proxy.isPresentingSplitView) - } -} - -extension SplitContentView { - - init(splitContentWidth: CGFloat = 400) { - self.init( - proxy: .init(), - content: { EmptyView() }, - splitContent: { EmptyView() }, - splitContentWidth: splitContentWidth - ) - } - - func proxy(_ proxy: SplitContentViewProxy) -> Self { - copy(modifying: \.proxy, with: proxy) - } - - func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.content, with: content) - } - - func splitContent(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.splitContent, with: content) - } -} diff --git a/jellypig iOS/Components/UnmaskSecureField.swift b/jellypig iOS/Components/UnmaskSecureField.swift deleted file mode 100644 index b793dfdf..00000000 --- a/jellypig iOS/Components/UnmaskSecureField.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: use _UIHostingView for button animation workaround? -// - have a nice animation for toggle - -struct UnmaskSecureField: UIViewRepresentable { - - @Binding - private var text: String - - private let onReturn: () -> Void - private let title: String - - init( - _ title: String, - text: Binding, - onReturn: @escaping () -> Void = {} - ) { - self._text = text - self.title = title - self.onReturn = onReturn - } - - func makeUIView(context: Context) -> UITextField { - - let textField = UITextField() - textField.font = context.environment.font?.uiFont ?? UIFont.preferredFont(forTextStyle: .body) - textField.adjustsFontForContentSizeCategory = true - textField.isSecureTextEntry = true - textField.keyboardType = .asciiCapable - textField.placeholder = title - textField.text = text - textField.addTarget( - context.coordinator, - action: #selector(Coordinator.textDidChange), - for: .editingChanged - ) - - let button = UIButton(type: .custom) - button.translatesAutoresizingMaskIntoConstraints = false - button.addTarget( - context.coordinator, - action: #selector(Coordinator.buttonPressed), - for: .touchUpInside - ) - button.setImage( - UIImage(systemName: "eye.fill"), - for: .normal - ) - - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 50), - button.widthAnchor.constraint(equalToConstant: 50), - ]) - - textField.rightView = button - textField.rightViewMode = .always - - context.coordinator.button = button - context.coordinator.onReturn = onReturn - context.coordinator.textField = textField - context.coordinator.textDidChange() - context.coordinator.textBinding = _text - - textField.delegate = context.coordinator - - return textField - } - - func updateUIView(_ textField: UITextField, context: Context) { - if text != textField.text { - textField.text = text - } - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - class Coordinator: NSObject, UITextFieldDelegate { - - weak var button: UIButton? - weak var textField: UITextField? - var textBinding: Binding = .constant("") - var onReturn: () -> Void = {} - - @objc - func buttonPressed() { - guard let textField else { return } - textField.toggleSecureEntry() - - let eye = textField.isSecureTextEntry ? "eye.fill" : "eye.slash" - button?.setImage(UIImage(systemName: eye), for: .normal) - } - - @objc - func textDidChange() { - guard let textField, let text = textField.text else { return } - button?.isEnabled = !text.isEmpty - textBinding.wrappedValue = text - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - onReturn() - return true - } - } -} - -private extension UITextField { - - // https://stackoverflow.com/a/48115361 - func toggleSecureEntry() { - - isSecureTextEntry.toggle() - - if let existingText = text, isSecureTextEntry { - deleteBackward() - - if let textRange = textRange(from: beginningOfDocument, to: endOfDocument) { - replace(textRange, withText: existingText) - } - } - - if let existingSelectedTextRange = selectedTextRange { - selectedTextRange = nil - selectedTextRange = existingSelectedTextRange - } - } -} diff --git a/jellypig iOS/Components/UpdateView.swift b/jellypig iOS/Components/UpdateView.swift deleted file mode 100644 index 8f93a180..00000000 --- a/jellypig iOS/Components/UpdateView.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: Better name -// TODO: don't use pushed to indicate a presented value - -class UpdateViewProxy: ObservableObject { - - @Published - private(set) var systemName: String? = nil - @Published - private(set) var iconSize: CGSize = .init(width: 25, height: 25) - @Published - private(set) var title: String = "" - @Published - private(set) var pushed: Bool = false - - func present(systemName: String, title: String, iconSize: CGSize = .init(width: 25, height: 25)) { - self.systemName = systemName - self.iconSize = iconSize - self.title = title - pushed.toggle() - } -} - -struct UpdateView: View { - - @ObservedObject - private var proxy: UpdateViewProxy - - @State - private var isPresenting: Bool = false - @State - private var workItem: DispatchWorkItem? - - init(proxy: UpdateViewProxy) { - self.proxy = proxy - } - - var body: some View { - ZStack { - if isPresenting { - HStack { - if let systemName = proxy.systemName { - Image(systemName: systemName) - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: proxy.iconSize.width, maxHeight: proxy.iconSize.height, alignment: .center) - } - - Text(proxy.title) - .font(.body) - .fontWeight(.bold) - .monospacedDigit() - } - .padding(.horizontal, 24) - .padding(.vertical, 8) - .frame(minHeight: 50) - .background(BlurView()) - .clipShape(Capsule()) - .overlay(Capsule().stroke(Color.gray.opacity(0.2), lineWidth: 1)) - .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 6) - .compositingGroup() - .transition(.opacity) - } - } - .animation(.linear(duration: 0.1), value: proxy.systemName) - .animation(.linear(duration: 0.1), value: proxy.iconSize) - .onChange(of: proxy.pushed) { _ in - - if !isPresenting { - withAnimation { - isPresenting = true - } - } - - workItem?.cancel() - - let task = DispatchWorkItem { - withAnimation(.spring()) { - isPresenting = false - } - } - workItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: task) - } - } -} diff --git a/jellypig iOS/Components/Video3DFormatPicker.swift b/jellypig iOS/Components/Video3DFormatPicker.swift deleted file mode 100644 index f5916cde..00000000 --- a/jellypig iOS/Components/Video3DFormatPicker.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct Video3DFormatPicker: View { - let title: String - @Binding - var selectedFormat: Video3DFormat? - - var body: some View { - Picker(title, selection: $selectedFormat) { - Text(L10n.none).tag(nil as Video3DFormat?) - ForEach(Video3DFormat.allCases, id: \.self) { format in - Text(format.displayTitle).tag(format as Video3DFormat?) - } - } - } -} diff --git a/jellypig iOS/Components/iOS15View.swift b/jellypig iOS/Components/iOS15View.swift deleted file mode 100644 index 1c237322..00000000 --- a/jellypig iOS/Components/iOS15View.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: remove when iOS 15 support removed -struct iOS15View: View { - - let iOS15: () -> iOS15Content - let content: () -> Content - - var body: some View { - if #available(iOS 16, *) { - content() - } else { - iOS15() - } - } -} diff --git a/jellypig iOS/Extensions/ButtonStyle-iOS.swift b/jellypig iOS/Extensions/ButtonStyle-iOS.swift deleted file mode 100644 index 5e3da819..00000000 --- a/jellypig iOS/Extensions/ButtonStyle-iOS.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension ButtonStyle where Self == ToolbarPillButtonStyle { - - static var toolbarPill: ToolbarPillButtonStyle { - ToolbarPillButtonStyle(primary: Defaults[.accentColor], secondary: .secondary) - } - - static func toolbarPill(_ primary: Color, _ secondary: Color = Color.secondary) -> ToolbarPillButtonStyle { - ToolbarPillButtonStyle(primary: primary, secondary: secondary) - } -} - -// TODO: don't take `Color`, take generic `ShapeStyle` -struct ToolbarPillButtonStyle: ButtonStyle { - - @Environment(\.isEnabled) - private var isEnabled - - let primary: Color - let secondary: Color - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .foregroundStyle(isEnabled ? primary.overlayColor : secondary) - .font(.headline) - .padding(.vertical, 5) - .padding(.horizontal, 10) - .background(isEnabled ? primary : secondary) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .opacity(isEnabled && !configuration.isPressed ? 1 : 0.5) - } -} diff --git a/jellypig iOS/Extensions/Label-iOS.swift b/jellypig iOS/Extensions/Label-iOS.swift deleted file mode 100644 index 02f82ad0..00000000 --- a/jellypig iOS/Extensions/Label-iOS.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: see if could be moved to `Shared` - -// MARK: EpisodeSelectorLabelStyle - -extension LabelStyle where Self == EpisodeSelectorLabelStyle { - - static var episodeSelector: EpisodeSelectorLabelStyle { - EpisodeSelectorLabelStyle() - } -} - -struct EpisodeSelectorLabelStyle: LabelStyle { - - func makeBody(configuration: Configuration) -> some View { - HStack { - configuration.title - - configuration.icon - } - .font(.headline) - .padding(.vertical, 5) - .padding(.horizontal, 10) - .background { - Color.tertiarySystemFill - .cornerRadius(10) - } - .compositingGroup() - .shadow(radius: 1) - .font(.caption) - } -} - -// MARK: SectionFooterWithImageLabelStyle - -// TODO: rename as not only used in section footers - -extension LabelStyle where Self == SectionFooterWithImageLabelStyle { - - static func sectionFooterWithImage(imageStyle: ImageStyle) -> SectionFooterWithImageLabelStyle { - SectionFooterWithImageLabelStyle(imageStyle: imageStyle) - } -} - -struct SectionFooterWithImageLabelStyle: LabelStyle { - - let imageStyle: ImageStyle - - func makeBody(configuration: Configuration) -> some View { - HStack { - configuration.icon - .foregroundStyle(imageStyle) - .backport - .fontWeight(.bold) - - configuration.title - } - } -} diff --git a/jellypig iOS/Extensions/View/Modifiers/DetectOrientationModifier.swift b/jellypig iOS/Extensions/View/Modifiers/DetectOrientationModifier.swift deleted file mode 100644 index fb24906b..00000000 --- a/jellypig iOS/Extensions/View/Modifiers/DetectOrientationModifier.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct DetectOrientation: ViewModifier { - - @Binding - var orientation: UIDeviceOrientation - - func body(content: Content) -> some View { - content - .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - orientation = UIDevice.current.orientation - } - } -} diff --git a/jellypig iOS/Extensions/View/Modifiers/NavigationBarCloseButton.swift b/jellypig iOS/Extensions/View/Modifiers/NavigationBarCloseButton.swift deleted file mode 100644 index 6ab0cfc7..00000000 --- a/jellypig iOS/Extensions/View/Modifiers/NavigationBarCloseButton.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct NavigationBarCloseButtonModifier: ViewModifier { - - @Default(.accentColor) - private var accentColor - - let disabled: Bool - let action: () -> Void - - func body(content: Content) -> some View { - content.toolbar { - ToolbarItemGroup(placement: .topBarLeading) { - Button { - action() - } label: { - Image(systemName: "xmark.circle.fill") - .backport - .fontWeight(.bold) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - .opacity(disabled ? 0.75 : 1) - } - .disabled(disabled) - } - } - } -} diff --git a/jellypig iOS/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift b/jellypig iOS/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift deleted file mode 100644 index c6b363bf..00000000 --- a/jellypig iOS/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct NavigationBarDrawerModifier: ViewModifier { - - private let drawer: () -> Drawer - - init(@ViewBuilder drawer: @escaping () -> Drawer) { - self.drawer = drawer - } - - func body(content: Content) -> some View { - NavigationBarDrawerView { - drawer() - .ignoresSafeArea() - } content: { - content - } - .ignoresSafeArea() - } -} diff --git a/jellypig iOS/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift b/jellypig iOS/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift deleted file mode 100644 index fe58581b..00000000 --- a/jellypig iOS/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct NavigationBarDrawerView: UIViewControllerRepresentable { - - private let buttons: () -> Drawer - private let content: () -> Content - - init( - @ViewBuilder buttons: @escaping () -> Drawer, - @ViewBuilder content: @escaping () -> Content - ) { - self.buttons = buttons - self.content = content - } - - func makeUIViewController(context: Context) -> UINavigationBarDrawerHostingController { - UINavigationBarDrawerHostingController(buttons: buttons, content: content) - } - - func updateUIViewController(_ uiViewController: UINavigationBarDrawerHostingController, context: Context) {} -} - -class UINavigationBarDrawerHostingController: UIHostingController { - - private let drawer: () -> Drawer - private let content: () -> Content - - // TODO: see if we can get the height instead from the view passed in - private let drawerHeight: CGFloat = 36 - - private lazy var blurView: UIVisualEffectView = { - let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial)) - blurView.translatesAutoresizingMaskIntoConstraints = false - return blurView - }() - - private lazy var drawerButtonsView: UIHostingController = { - let drawerButtonsView = UIHostingController(rootView: drawer()) - drawerButtonsView.view.translatesAutoresizingMaskIntoConstraints = false - drawerButtonsView.view.backgroundColor = nil - return drawerButtonsView - }() - - init( - buttons: @escaping () -> Drawer, - content: @escaping () -> Content - ) { - self.drawer = buttons - self.content = content - - super.init(rootView: content()) - } - - @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = nil - - view.addSubview(blurView) - - addChild(drawerButtonsView) - view.addSubview(drawerButtonsView.view) - drawerButtonsView.didMove(toParent: self) - - NSLayoutConstraint.activate([ - drawerButtonsView.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: -drawerHeight), - drawerButtonsView.view.heightAnchor.constraint(equalToConstant: drawerHeight), - drawerButtonsView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - drawerButtonsView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) - - NSLayoutConstraint.activate([ - blurView.topAnchor.constraint(equalTo: view.topAnchor), - blurView.bottomAnchor.constraint(equalTo: drawerButtonsView.view.bottomAnchor), - blurView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - blurView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) - navigationController?.navigationBar.shadowImage = UIImage() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - navigationController?.navigationBar.setBackgroundImage(nil, for: .default) - navigationController?.navigationBar.shadowImage = nil - } - - override var additionalSafeAreaInsets: UIEdgeInsets { - get { - .init(top: drawerHeight, left: 0, bottom: 0, right: 0) - } - set { - super.additionalSafeAreaInsets = .init(top: drawerHeight, left: 0, bottom: 0, right: 0) - } - } -} diff --git a/jellypig iOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift b/jellypig iOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift deleted file mode 100644 index 0eaa8089..00000000 --- a/jellypig iOS/Extensions/View/Modifiers/NavigationBarMenuButton.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct NavigationBarMenuButtonModifier: ViewModifier { - - @Default(.accentColor) - private var accentColor - - let isLoading: Bool - let isHidden: Bool - let items: () -> Content - - func body(content: Self.Content) -> some View { - content.toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - - if isLoading { - ProgressView() - } - - if !isHidden { - Menu(L10n.options, systemImage: "ellipsis.circle") { - items() - } - .labelStyle(.iconOnly) - .backport - .fontWeight(.semibold) - .foregroundStyle(accentColor) - } - } - } - } -} diff --git a/jellypig iOS/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetModifier.swift b/jellypig iOS/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetModifier.swift deleted file mode 100644 index 2c5469c6..00000000 --- a/jellypig iOS/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetModifier.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct NavigationBarOffsetModifier: ViewModifier { - - @Binding - var scrollViewOffset: CGFloat - - let start: CGFloat - let end: CGFloat - - func body(content: Content) -> some View { - NavigationBarOffsetView( - scrollViewOffset: $scrollViewOffset, - start: start, - end: end - ) { - content - } - .ignoresSafeArea() - } -} diff --git a/jellypig iOS/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetView.swift b/jellypig iOS/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetView.swift deleted file mode 100644 index 24753167..00000000 --- a/jellypig iOS/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetView.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct NavigationBarOffsetView: UIViewControllerRepresentable { - - @Binding - private var scrollViewOffset: CGFloat - - private let start: CGFloat - private let end: CGFloat - private let content: () -> Content - - init( - scrollViewOffset: Binding, - start: CGFloat, - end: CGFloat, - @ViewBuilder content: @escaping () -> Content - ) { - self._scrollViewOffset = scrollViewOffset - self.start = start - self.end = end - self.content = content - } - - func makeUIViewController(context: Context) -> UINavigationBarOffsetHostingController { - UINavigationBarOffsetHostingController(rootView: content()) - } - - func updateUIViewController(_ uiViewController: UINavigationBarOffsetHostingController, context: Context) { - uiViewController.scrollViewDidScroll(scrollViewOffset, start: start, end: end) - } -} - -class UINavigationBarOffsetHostingController: UIHostingController { - - private var lastAlpha: CGFloat = 0 - - private lazy var blurView: UIVisualEffectView = { - let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial)) - blurView.translatesAutoresizingMaskIntoConstraints = false - return blurView - }() - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = nil - - view.addSubview(blurView) - blurView.alpha = 0 - - NSLayoutConstraint.activate([ - blurView.topAnchor.constraint(equalTo: view.topAnchor), - blurView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), - blurView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - blurView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) - } - - func scrollViewDidScroll(_ offset: CGFloat, start: CGFloat, end: CGFloat) { - - let diff = end - start - let currentProgress = (offset - start) / diff - let alpha = clamp(currentProgress, min: 0, max: 1) - - navigationController?.navigationBar - .titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label.withAlphaComponent(alpha)] - blurView.alpha = alpha - lastAlpha = alpha - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - navigationController?.navigationBar - .titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label.withAlphaComponent(lastAlpha)] - navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) - navigationController?.navigationBar.shadowImage = UIImage() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - navigationController?.navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label] - navigationController?.navigationBar.setBackgroundImage(nil, for: .default) - navigationController?.navigationBar.shadowImage = nil - } -} diff --git a/jellypig iOS/Extensions/View/View-iOS.swift b/jellypig iOS/Extensions/View/View-iOS.swift deleted file mode 100644 index 2a64f993..00000000 --- a/jellypig iOS/Extensions/View/View-iOS.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import SwiftUIIntrospect - -extension View { - - // TODO: remove after removing support for iOS 15 - - @ViewBuilder - func iOS15(@ViewBuilder _ content: (Self) -> Content) -> some View { - if #available(iOS 16, *) { - self - } else { - content(self) - } - } - - func detectOrientation(_ orientation: Binding) -> some View { - modifier(DetectOrientation(orientation: orientation)) - } - - func navigationBarOffset(_ scrollViewOffset: Binding, start: CGFloat, end: CGFloat) -> some View { - modifier(NavigationBarOffsetModifier(scrollViewOffset: scrollViewOffset, start: start, end: end)) - } - - func navigationBarDrawer(@ViewBuilder _ drawer: @escaping () -> Drawer) -> some View { - modifier(NavigationBarDrawerModifier(drawer: drawer)) - } - - @ViewBuilder - func navigationBarFilterDrawer( - viewModel: FilterViewModel, - types: [ItemFilterType], - onSelect: @escaping (FilterCoordinator.Parameters) -> Void - ) -> some View { - if types.isEmpty { - self - } else { - navigationBarDrawer { - NavigationBarFilterDrawer( - viewModel: viewModel, - types: types - ) - .onSelect(onSelect) - } - } - } - - func onAppDidEnterBackground(_ action: @escaping () -> Void) -> some View { - onNotification(.applicationDidEnterBackground) { - action() - } - } - - func onAppWillResignActive(_ action: @escaping () -> Void) -> some View { - onNotification(.applicationWillResignActive) { _ in - action() - } - } - - func onAppWillTerminate(_ action: @escaping () -> Void) -> some View { - onNotification(.applicationWillTerminate) { _ in - action() - } - } - - @ViewBuilder - func navigationBarCloseButton( - disabled: Bool = false, - _ action: @escaping () -> Void - ) -> some View { - modifier( - NavigationBarCloseButtonModifier( - disabled: disabled, - action: action - ) - ) - } - - @ViewBuilder - func navigationBarMenuButton( - isLoading: Bool = false, - isHidden: Bool = false, - @ViewBuilder - _ items: @escaping () -> Content - ) -> some View { - modifier( - NavigationBarMenuButtonModifier( - isLoading: isLoading, - isHidden: isHidden, - items: items - ) - ) - } - - @ViewBuilder - func listRowCornerRadius(_ radius: CGFloat) -> some View { - if #unavailable(iOS 16) { - introspect(.listCell, on: .iOS(.v15)) { cell in - cell.layer.cornerRadius = radius - } - } else { - introspect( - .listCell, - on: .iOS(.v16), - .iOS(.v17), - .iOS(.v18) - ) { cell in - cell.layer.cornerRadius = radius - } - } - } -} diff --git a/jellypig iOS/Objects/AppURLHandler.swift b/jellypig iOS/Objects/AppURLHandler.swift deleted file mode 100644 index f6ad987d..00000000 --- a/jellypig iOS/Objects/AppURLHandler.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Foundation -import JellyfinAPI -import Stinsen - -final class AppURLHandler { - static let deepLinkScheme = "jellyfin" - - enum AppURLState { - case launched - case allowedInLogin - case allowed - - func allowedScheme(with url: URL) -> Bool { - switch self { - case .launched: - return false - case .allowed: - return true - case .allowedInLogin: - return false - } - } - } - - static let shared = AppURLHandler() - - var cancellables = Set() - - var appURLState: AppURLState = .launched - var launchURL: URL? -} - -extension AppURLHandler { - @discardableResult - func processDeepLink(url: URL) -> Bool { - guard url.scheme == Self.deepLinkScheme || url.scheme == "widget-extension" else { - return false - } - if AppURLHandler.shared.appURLState.allowedScheme(with: url) { - return processURL(url) - } else { - launchURL = url - } - return true - } - - func processLaunchedURLIfNeeded() { - guard let launchURL = launchURL, - launchURL.absoluteString.isNotEmpty else { return } - if processDeepLink(url: launchURL) { - self.launchURL = nil - } - } - - private func processURL(_ url: URL) -> Bool { - if processURLForUser(url: url) { - return true - } - - return false - } - - private func processURLForUser(url: URL) -> Bool { - guard url.host?.lowercased() == "users", - url.pathComponents[safe: 1]?.isEmpty == false else { return false } - - // /Users/{UserID}/Items/{ItemID} - if url.pathComponents[safe: 2]?.lowercased() == "items", - let userID = url.pathComponents[safe: 1], - let itemID = url.pathComponents[safe: 3] - { - // It would be nice if the ItemViewModel could be initialized to id later. - getItem(userID: userID, itemID: itemID) { item in - guard let item = item else { return } - // TODO: reimplement URL handling -// Notifications[.processDeepLink].post(DeepLink.item(item)) - } - - return true - } - - return false - } -} - -extension AppURLHandler { - func getItem(userID: String, itemID: String, completion: @escaping (BaseItemDto?) -> Void) { -// UserLibraryAPI.getItem(userId: userID, itemId: itemID) -// .sink(receiveCompletion: { innerCompletion in -// switch innerCompletion { -// case .failure: -// completion(nil) -// default: -// break -// } -// }, receiveValue: { item in -// completion(item) -// }) -// .store(in: &cancellables) - } -} diff --git a/jellypig iOS/Objects/DeepLink.swift b/jellypig iOS/Objects/DeepLink.swift deleted file mode 100644 index 20d226b7..00000000 --- a/jellypig iOS/Objects/DeepLink.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI - -enum DeepLink { - case item(BaseItemDto) -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/AppIcon-dark-blue.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/AppIcon-dark-blue.svg deleted file mode 100644 index 0359f76d..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/AppIcon-dark-blue.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-dark-blue - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/Contents.json deleted file mode 100644 index ad53b2dc..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-blue.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-blue.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-green.imageset/AppIcon-dark-green.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-green.imageset/AppIcon-dark-green.svg deleted file mode 100644 index fd482fa9..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-green.imageset/AppIcon-dark-green.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-dark-green - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-green.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-green.imageset/Contents.json deleted file mode 100644 index 1f84f6cb..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-green.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-green.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/AppIcon-dark-jellyfin.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/AppIcon-dark-jellyfin.svg deleted file mode 100644 index df67de89..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/AppIcon-dark-jellyfin.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-dark-jellyfin - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/Contents.json deleted file mode 100644 index 151add20..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-jellyfin.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-jellyfin.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/AppIcon-dark-orange.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/AppIcon-dark-orange.svg deleted file mode 100644 index 62a768a5..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/AppIcon-dark-orange.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-dark-orange - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/Contents.json deleted file mode 100644 index c1b388f6..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-orange.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-orange.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-red.imageset/AppIcon-dark-red.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-red.imageset/AppIcon-dark-red.svg deleted file mode 100644 index 6c848265..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-red.imageset/AppIcon-dark-red.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-dark-red - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-red.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-red.imageset/Contents.json deleted file mode 100644 index 7158d1b8..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-red.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-red.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/AppIcon-dark-yellow.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/AppIcon-dark-yellow.svg deleted file mode 100644 index 9787edce..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/AppIcon-dark-yellow.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-dark-yellow - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/Contents.json deleted file mode 100644 index a437da5b..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-dark-yellow.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-yellow.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/AppIcon-invertedDark-blue.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/AppIcon-invertedDark-blue.svg deleted file mode 100644 index 5183ef54..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/AppIcon-invertedDark-blue.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedDark-blue - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/Contents.json deleted file mode 100644 index ee9343e8..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-blue.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedDark-blue.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/AppIcon-invertedDark-green.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/AppIcon-invertedDark-green.svg deleted file mode 100644 index d0438c88..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/AppIcon-invertedDark-green.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedDark-green - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/Contents.json deleted file mode 100644 index 7776bbd2..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-green.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedDark-green.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/AppIcon-invertedDark-jellyfin.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/AppIcon-invertedDark-jellyfin.svg deleted file mode 100644 index f551bcb8..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/AppIcon-invertedDark-jellyfin.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedDark-jellyfin - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/Contents.json deleted file mode 100644 index 5a7cb929..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-jellyfin.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedDark-jellyfin.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/AppIcon-invertedDark-orange.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/AppIcon-invertedDark-orange.svg deleted file mode 100644 index 855ae608..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/AppIcon-invertedDark-orange.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedDark-orange - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/Contents.json deleted file mode 100644 index f58bd11f..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-orange.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedDark-orange.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/AppIcon-invertedDark-red.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/AppIcon-invertedDark-red.svg deleted file mode 100644 index 6d6f5085..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/AppIcon-invertedDark-red.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedDark-red - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/Contents.json deleted file mode 100644 index 745880cd..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-red.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedDark-red.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/AppIcon-invertedDark-yellow.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/AppIcon-invertedDark-yellow.svg deleted file mode 100644 index 9935c5cd..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/AppIcon-invertedDark-yellow.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedDark-yellow - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/Contents.json deleted file mode 100644 index acddd76a..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedDark-yellow.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedDark-yellow.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/AppIcon-invertedLight-blue.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/AppIcon-invertedLight-blue.svg deleted file mode 100644 index 7fe53b01..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/AppIcon-invertedLight-blue.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedLight-blue - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/Contents.json deleted file mode 100644 index 919ded90..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-blue.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedLight-blue.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/AppIcon-invertedLight-green.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/AppIcon-invertedLight-green.svg deleted file mode 100644 index 1b434671..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/AppIcon-invertedLight-green.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedLight-green - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/Contents.json deleted file mode 100644 index e75c6e7f..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-green.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedLight-green.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/AppIcon-invertedLight-jellyfin.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/AppIcon-invertedLight-jellyfin.svg deleted file mode 100644 index 03df4bb1..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/AppIcon-invertedLight-jellyfin.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedLight-jellyfin - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/Contents.json deleted file mode 100644 index 40b430ea..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-jellyfin.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedLight-jellyfin.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/AppIcon-invertedLight-orange.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/AppIcon-invertedLight-orange.svg deleted file mode 100644 index 46a6c39b..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/AppIcon-invertedLight-orange.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedLight-orange - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/Contents.json deleted file mode 100644 index 7e5cb9c8..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-orange.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedLight-orange.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/AppIcon-invertedLight-red.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/AppIcon-invertedLight-red.svg deleted file mode 100644 index 9f251a13..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/AppIcon-invertedLight-red.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedLight-red - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/Contents.json deleted file mode 100644 index 97664edb..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-red.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedLight-red.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/AppIcon-invertedLight-yellow.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/AppIcon-invertedLight-yellow.svg deleted file mode 100644 index 72209c33..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/AppIcon-invertedLight-yellow.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-invertedLight-yellow - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/Contents.json deleted file mode 100644 index c48c59cc..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-invertedLight-yellow.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-invertedLight-yellow.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-blue.imageset/AppIcon-light-blue.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-blue.imageset/AppIcon-light-blue.svg deleted file mode 100644 index bc41f6e1..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-blue.imageset/AppIcon-light-blue.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-light-blue - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-blue.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-blue.imageset/Contents.json deleted file mode 100644 index 6e49249a..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-blue.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-blue.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-green.imageset/AppIcon-light-green.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-green.imageset/AppIcon-light-green.svg deleted file mode 100644 index b48a3ce1..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-green.imageset/AppIcon-light-green.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-light-green - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-green.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-green.imageset/Contents.json deleted file mode 100644 index ca6d643d..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-green.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-green.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/AppIcon-light-jellyfin.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/AppIcon-light-jellyfin.svg deleted file mode 100644 index d23e85d5..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/AppIcon-light-jellyfin.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-light-jellyfin - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/Contents.json deleted file mode 100644 index 17df257e..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-jellyfin.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-jellyfin.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-orange.imageset/AppIcon-light-orange.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-orange.imageset/AppIcon-light-orange.svg deleted file mode 100644 index bb1a78ef..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-orange.imageset/AppIcon-light-orange.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-light-orange - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-orange.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-orange.imageset/Contents.json deleted file mode 100644 index 482ac97e..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-orange.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-orange.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-red.imageset/AppIcon-light-red.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-red.imageset/AppIcon-light-red.svg deleted file mode 100644 index 9101ca42..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-red.imageset/AppIcon-light-red.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-light-red - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-red.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-red.imageset/Contents.json deleted file mode 100644 index 15cfbf82..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-red.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-red.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/AppIcon-light-yellow.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/AppIcon-light-yellow.svg deleted file mode 100644 index 08b69104..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/AppIcon-light-yellow.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - AppIcon-light-yellow - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/Contents.json deleted file mode 100644 index 892b39fa..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-light-yellow.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-yellow.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/AppIcon-primary-primary.svg b/jellypig iOS/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/AppIcon-primary-primary.svg deleted file mode 100644 index b9734257..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/AppIcon-primary-primary.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - primary - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/Contents.json deleted file mode 100644 index fb781a9c..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcon-primary-primary.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-primary-primary.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/AppIcon-dark-blue.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/AppIcon-dark-blue.png deleted file mode 100644 index f56341f3..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/AppIcon-dark-blue.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/Contents.json deleted file mode 100644 index 5e53a352..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-blue.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-blue.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/AppIcon-dark-green.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/AppIcon-dark-green.png deleted file mode 100644 index 8fc342f6..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/AppIcon-dark-green.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/Contents.json deleted file mode 100644 index 2698c56b..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-green.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-green.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/AppIcon-dark-jellyfin.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/AppIcon-dark-jellyfin.png deleted file mode 100644 index a1de8f95..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/AppIcon-dark-jellyfin.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/Contents.json deleted file mode 100644 index 43c18692..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-jellyfin.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-jellyfin.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/AppIcon-dark-orange.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/AppIcon-dark-orange.png deleted file mode 100644 index 04c2b7fa..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/AppIcon-dark-orange.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/Contents.json deleted file mode 100644 index 0cce5543..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-orange.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-orange.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/AppIcon-dark-red.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/AppIcon-dark-red.png deleted file mode 100644 index b576b17e..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/AppIcon-dark-red.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/Contents.json deleted file mode 100644 index 93aa3d95..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-red.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-red.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/AppIcon-dark-yellow.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/AppIcon-dark-yellow.png deleted file mode 100644 index a81a04f9..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/AppIcon-dark-yellow.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/Contents.json deleted file mode 100644 index d228c5f4..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/AppIcon-dark-yellow.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-dark-yellow.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Dark/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/Contents.json deleted file mode 100644 index f576456f..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "blue.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/blue.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/blue.png deleted file mode 100644 index 833df3f7..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-blue.appiconset/blue.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/Contents.json deleted file mode 100644 index bdf1b75e..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "green.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/green.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/green.png deleted file mode 100644 index 8e2f25f2..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-green.appiconset/green.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/Contents.json deleted file mode 100644 index b6aee0a8..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "jellyfin.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/jellyfin.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/jellyfin.png deleted file mode 100644 index 988a97ec..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-jellyfin.appiconset/jellyfin.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/Contents.json deleted file mode 100644 index 43c3f0b1..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "orange.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/orange.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/orange.png deleted file mode 100644 index 6d95620d..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-orange.appiconset/orange.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/Contents.json deleted file mode 100644 index a3eb9a85..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "red.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/red.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/red.png deleted file mode 100644 index db99e9a0..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-red.appiconset/red.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/Contents.json deleted file mode 100644 index 2b34f525..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "yellow.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/yellow.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/yellow.png deleted file mode 100644 index bd72e8ee..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/AppIcon-invertedDark-yellow.appiconset/yellow.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Dark/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/Contents.json deleted file mode 100644 index f576456f..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "blue.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/blue.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/blue.png deleted file mode 100644 index 545ffe83..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-blue.appiconset/blue.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/Contents.json deleted file mode 100644 index bdf1b75e..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "green.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/green.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/green.png deleted file mode 100644 index ba312208..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-green.appiconset/green.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/Contents.json deleted file mode 100644 index b6aee0a8..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "jellyfin.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/jellyfin.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/jellyfin.png deleted file mode 100644 index 91aa2dd4..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-jellyfin.appiconset/jellyfin.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/Contents.json deleted file mode 100644 index 43c3f0b1..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "orange.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/orange.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/orange.png deleted file mode 100644 index c25a02d9..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-orange.appiconset/orange.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/Contents.json deleted file mode 100644 index a3eb9a85..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "red.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/red.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/red.png deleted file mode 100644 index c96f50ad..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-red.appiconset/red.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/Contents.json deleted file mode 100644 index 2b34f525..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "yellow.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/yellow.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/yellow.png deleted file mode 100644 index 29bc419b..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/AppIcon-invertedLight-yellow.appiconset/yellow.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Inverted-Light/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/AppIcon-light-blue.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/AppIcon-light-blue.png deleted file mode 100644 index 964a0c99..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/AppIcon-light-blue.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/Contents.json deleted file mode 100644 index a1ed8c80..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-blue.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-blue.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/AppIcon-light-green.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/AppIcon-light-green.png deleted file mode 100644 index 8918bc60..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/AppIcon-light-green.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/Contents.json deleted file mode 100644 index 7a435a7a..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-green.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-green.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/AppIcon-light-jellyfin.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/AppIcon-light-jellyfin.png deleted file mode 100644 index 93512970..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/AppIcon-light-jellyfin.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/Contents.json deleted file mode 100644 index 63e1b06b..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-jellyfin.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-jellyfin.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/AppIcon-light-orange.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/AppIcon-light-orange.png deleted file mode 100644 index 8b774a89..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/AppIcon-light-orange.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/Contents.json deleted file mode 100644 index bdec0e39..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-orange.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-orange.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/AppIcon-light-red.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/AppIcon-light-red.png deleted file mode 100644 index a7957d09..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/AppIcon-light-red.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/Contents.json deleted file mode 100644 index b42150ac..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-red.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-red.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/AppIcon-light-yellow.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/AppIcon-light-yellow.png deleted file mode 100644 index a42b65a7..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/AppIcon-light-yellow.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/Contents.json deleted file mode 100644 index 43413c67..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/AppIcon-light-yellow.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-light-yellow.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Light/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/AppIcon-primary-primary.png b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/AppIcon-primary-primary.png deleted file mode 100644 index 3b527bd7..00000000 Binary files a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/AppIcon-primary-primary.png and /dev/null differ diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/Contents.json deleted file mode 100644 index b9bffe0d..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Primary/AppIcon-primary-primary.appiconset/Contents.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-primary-primary.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Primary/Contents.json b/jellypig iOS/Resources/Assets.xcassets/AppIcons/Primary/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/AppIcons/Primary/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/Contents.json b/jellypig iOS/Resources/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json deleted file mode 100644 index 9d8a820a..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "chrome.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg deleted file mode 100644 index fab308dc..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-chrome.imageset/chrome.svg +++ /dev/null @@ -1 +0,0 @@ -Google Chrome icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json deleted file mode 100644 index 5029adef..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "edge.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg deleted file mode 100644 index 8a552924..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edge.imageset/edge.svg +++ /dev/null @@ -1 +0,0 @@ -Microsoft Edge icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json deleted file mode 100644 index 1913d0ce..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "edgechromium.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg deleted file mode 100644 index 14d68a5d..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-edgechromium.imageset/edgechromium.svg +++ /dev/null @@ -1 +0,0 @@ -Microsoft Edge icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json deleted file mode 100644 index 59e17e67..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "firefox.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg deleted file mode 100644 index 7f468b3f..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-firefox.imageset/firefox.svg +++ /dev/null @@ -1 +0,0 @@ -Mozilla Firefox icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json deleted file mode 100644 index 83f2b872..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "html5.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg deleted file mode 100644 index 63704799..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-html5.imageset/html5.svg +++ /dev/null @@ -1 +0,0 @@ -HTML5 icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json deleted file mode 100644 index 2b8e8f69..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "msie.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg deleted file mode 100644 index f5b362d7..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-msie.imageset/msie.svg +++ /dev/null @@ -1 +0,0 @@ -Internet Explorer icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json deleted file mode 100644 index e73067c2..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "opera.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg deleted file mode 100644 index dd57f924..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-opera.imageset/opera.svg +++ /dev/null @@ -1 +0,0 @@ -Opera icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json deleted file mode 100644 index feaf2495..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "safari.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg deleted file mode 100644 index 12abbb95..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Browsers/Device-browser-safari.imageset/safari.svg +++ /dev/null @@ -1 +0,0 @@ -safari icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json deleted file mode 100644 index 26c167e3..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "android.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg deleted file mode 100644 index 24edc8bb..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-android.imageset/android.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json deleted file mode 100644 index a011cbd8..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "apple.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg deleted file mode 100644 index 4477a452..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-apple.imageset/apple.svg +++ /dev/null @@ -1 +0,0 @@ -Apple diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json deleted file mode 100644 index 35feb3d5..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "finamp.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg deleted file mode 100644 index 8bd3a90c..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-finamp.imageset/finamp.svg +++ /dev/null @@ -1,7 +0,0 @@ - - Finamp icon - diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json deleted file mode 100644 index 99d22c49..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "kodi.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg deleted file mode 100644 index 3618149b..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-kodi.imageset/kodi.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json deleted file mode 100644 index 31c0ff53..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "playstation.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg deleted file mode 100644 index c6595340..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-playstation.imageset/playstation.svg +++ /dev/null @@ -1 +0,0 @@ -PlayStation icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json deleted file mode 100644 index 8ea8fc43..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "roku.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg deleted file mode 100644 index eb1e621b..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-roku.imageset/roku.svg +++ /dev/null @@ -1,7 +0,0 @@ - - Roku icon - diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json deleted file mode 100644 index cf998a9c..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "samsungtv.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg deleted file mode 100644 index afdd19e2..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-samsungtv.imageset/samsungtv.svg +++ /dev/null @@ -1 +0,0 @@ -Samsung icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json deleted file mode 100644 index 63b3674d..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "webOS.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg deleted file mode 100644 index 611ba963..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-webos.imageset/webOS.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json deleted file mode 100644 index e6f157d1..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "windows.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg deleted file mode 100644 index 531e72e1..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-windows.imageset/windows.svg +++ /dev/null @@ -1 +0,0 @@ -Windows icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json deleted file mode 100644 index 847c1b55..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "xbox.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg deleted file mode 100644 index 640dd34a..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Clients/Device-client-xbox.imageset/xbox.svg +++ /dev/null @@ -1 +0,0 @@ -Xbox icon diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json deleted file mode 100644 index 41d3d101..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "home-assistant.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg deleted file mode 100644 index a34be98d..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-homeassistant.imageset/home-assistant.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json deleted file mode 100644 index 44a54246..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "other.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg b/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg deleted file mode 100644 index 91e1d9e2..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/DeviceIcons/Other/Device-other-other.imageset/other.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/jellypig iOS/Resources/Assets.xcassets/git.commit.symbolset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/git.commit.symbolset/Contents.json deleted file mode 100644 index c6230986..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/git.commit.symbolset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "symbols" : [ - { - "filename" : "git.commit.svg", - "idiom" : "universal" - } - ] -} diff --git a/jellypig iOS/Resources/Assets.xcassets/git.commit.symbolset/git.commit.svg b/jellypig iOS/Resources/Assets.xcassets/git.commit.symbolset/git.commit.svg deleted file mode 100644 index 392aa6f1..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/git.commit.symbolset/git.commit.svg +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - - - Weight/Scale Variations - Ultralight - Thin - Light - Regular - Medium - Semibold - Bold - Heavy - Black - - - - - - - - - - - Design Variations - Symbols are supported in up to nine weights and three scales. - For optimal layout with text and other symbols, vertically align - symbols with the adjacent text. - - - - - - Margins - Leading and trailing margins on the left and right side of each symbol - can be adjusted by modifying the x-location of the margin guidelines. - Modifications are automatically applied proportionally to all - scales and weights. - - - - Exporting - Symbols should be outlined when exporting to ensure the - design is preserved when submitting to Xcode. - Template v.3.0 - Requires Xcode 13 or greater - Generated from git.commit - Typeset at 100 points - Small - Medium - Large - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/jellypig iOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json deleted file mode 100644 index 25a4a3a2..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "filename" : "jellyfin-blob.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true - } -} diff --git a/jellypig iOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg b/jellypig iOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg deleted file mode 100644 index db72d151..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/jellyfin-blob-blue.imageset/jellyfin-blob.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - Combined Shape - - - - - - - - - - - - \ No newline at end of file diff --git a/jellypig iOS/Resources/Assets.xcassets/logo.github.symbolset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/logo.github.symbolset/Contents.json deleted file mode 100644 index 9ccfab3b..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/logo.github.symbolset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "symbols" : [ - { - "filename" : "logo.github.svg", - "idiom" : "universal" - } - ] -} diff --git a/jellypig iOS/Resources/Assets.xcassets/logo.github.symbolset/logo.github.svg b/jellypig iOS/Resources/Assets.xcassets/logo.github.symbolset/logo.github.svg deleted file mode 100644 index 2588c07a..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/logo.github.symbolset/logo.github.svg +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - - - Weight/Scale Variations - Ultralight - Thin - Light - Regular - Medium - Semibold - Bold - Heavy - Black - - - - - - - - - - - Design Variations - Symbols are supported in up to nine weights and three scales. - For optimal layout with text and other symbols, vertically align - symbols with the adjacent text. - - - - - - Margins - Leading and trailing margins on the left and right side of each symbol - can be adjusted by modifying the x-location of the margin guidelines. - Modifications are automatically applied proportionally to all - scales and weights. - - - - Exporting - Symbols should be outlined when exporting to ensure the - design is preserved when submitting to Xcode. - Template v.3.0 - Requires Xcode 13 or greater - Generated from logo.github - Typeset at 100 points - Small - Medium - Large - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/jellypig iOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json deleted file mode 100644 index 3d67d001..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/tomato.fresh.symbolset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "symbols" : [ - { - "filename" : "tomato.fresh.svg", - "idiom" : "universal" - } - ] -} diff --git a/jellypig iOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg b/jellypig iOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg deleted file mode 100644 index d84fcc47..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/tomato.fresh.symbolset/tomato.fresh.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - Weight/Scale Variations - Ultralight - Thin - Light - Regular - Medium - Semibold - Bold - Heavy - Black - - - - - - - - - - - Design Variations - Symbols are supported in up to nine weights and three scales. - For optimal layout with text and other symbols, vertically align - symbols with the adjacent text. - - - - - - Margins - Leading and trailing margins on the left and right side of each symbol - can be adjusted by modifying the x-location of the margin guidelines. - Modifications are automatically applied proportionally to all - scales and weights. - - - - Exporting - Symbols should be outlined when exporting to ensure the - design is preserved when submitting to Xcode. - Template v.4.0 - Requires Xcode 14 or greater - Generated from tomato.fresh - Typeset at 100 points - Small - Medium - Large - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/jellypig iOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json b/jellypig iOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json deleted file mode 100644 index 4539290b..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/tomato.rotten.symbolset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "symbols" : [ - { - "filename" : "tomato.rotten.svg", - "idiom" : "universal" - } - ] -} diff --git a/jellypig iOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg b/jellypig iOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg deleted file mode 100644 index 47e742da..00000000 --- a/jellypig iOS/Resources/Assets.xcassets/tomato.rotten.symbolset/tomato.rotten.svg +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - Weight/Scale Variations - Ultralight - Thin - Light - Regular - Medium - Semibold - Bold - Heavy - Black - - - - - - - - - - - Design Variations - Symbols are supported in up to nine weights and three scales. - For optimal layout with text and other symbols, vertically align - symbols with the adjacent text. - - - - - - Margins - Leading and trailing margins on the left and right side of each symbol - can be adjusted by modifying the x-location of the margin guidelines. - Modifications are automatically applied proportionally to all - scales and weights. - - - - Exporting - Symbols should be outlined when exporting to ensure the - design is preserved when submitting to Xcode. - Template v.4.0 - Requires Xcode 14 or greater - Generated from tomato.rotten - Typeset at 100 points - Small - Medium - Large - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/jellypig iOS/Resources/Info.plist b/jellypig iOS/Resources/Info.plist deleted file mode 100644 index 54c52e68..00000000 --- a/jellypig iOS/Resources/Info.plist +++ /dev/null @@ -1,100 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - jellyfin - - - - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBluetoothAlwaysUsageDescription - ${PRODUCT_NAME} uses Bluetooth to discover nearby Cast devices. - NSBluetoothPeripheralUsageDescription - ${PRODUCT_NAME} uses Bluetooth to discover nearby Cast devices. - NSBonjourServices - - _googlecast._tcp - _F007D354._googlecast._tcp - - NSFaceIDUsageDescription - Use FaceID to lock and access local users. - NSLocalNetworkUsageDescription - ${PRODUCT_NAME} uses the local network to connect to your Jellyfin server & discover Cast-enabled devices on your WiFi -network. - NSMicrophoneUsageDescription - ${PRODUCT_NAME} uses microphone access to listen for ultrasonic tokens when pairing with nearby Cast devices. - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - audio - - UIFileSharingEnabled - - UILaunchScreen - - UIImageName - jellyfin-blob-blue - UIImageRespectsSafeAreaInsets - - - UIRequiredDeviceCapabilities - - armv7 - - UIRequiresFullScreen - - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationLandscapeLeft - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/jellypig iOS/Resources/Swiftfin.entitlements b/jellypig iOS/Resources/Swiftfin.entitlements deleted file mode 100644 index ee95ab7e..00000000 --- a/jellypig iOS/Resources/Swiftfin.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - - diff --git a/jellypig iOS/Views/AboutAppView.swift b/jellypig iOS/Views/AboutAppView.swift deleted file mode 100644 index 4e39e814..00000000 --- a/jellypig iOS/Views/AboutAppView.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct AboutAppView: View { - - @ObservedObject - var viewModel: SettingsViewModel - - var body: some View { - List { - Section { - VStack(alignment: .center, spacing: 10) { - - Image(.jellyfinBlobBlue) - .resizable() - .aspectRatio(1, contentMode: .fit) - .frame(height: 150) - - Text(verbatim: "Swiftfin") - .fontWeight(.semibold) - .font(.title2) - } - .frame(maxWidth: .infinity) - .listRowBackground(Color.clear) - } - - Section { - - TextPairView( - leading: L10n.version, - trailing: "\(UIApplication.appVersion ?? .emptyDash) (\(UIApplication.bundleVersion ?? .emptyDash))" - ) - - ChevronButton( - L10n.sourceCode, - image: .logoGithub, - external: true - ) { - UIApplication.shared.open(.swiftfinGithub) - } - - ChevronButton( - L10n.bugsAndFeatures, - systemName: "plus.circle.fill", - external: true - ) { - UIApplication.shared.open(.swiftfinGithubIssues) - } - .symbolRenderingMode(.monochrome) - - ChevronButton( - L10n.settings, - systemName: "gearshape.fill", - external: true - ) { - guard let url = URL(string: UIApplication.openSettingsURLString) else { return } - UIApplication.shared.open(url) - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/APIKeyView/APIKeysView.swift b/jellypig iOS/Views/AdminDashboardView/APIKeyView/APIKeysView.swift deleted file mode 100644 index 0c89dd38..00000000 --- a/jellypig iOS/Views/AdminDashboardView/APIKeyView/APIKeysView.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct APIKeysView: View { - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @State - private var showCopiedAlert = false - @State - private var showDeleteConfirmation = false - @State - private var showCreateAPIAlert = false - @State - private var newAPIName: String = "" - @State - private var deleteAPI: AuthenticationInfo? - - @StateObject - private var viewModel = APIKeysViewModel() - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - contentView - case let .error(error): - ErrorView(error: error) - .onRetry { - viewModel.send(.getAPIKeys) - } - case .initial: - DelayedProgressView() - } - } - .animation(.linear(duration: 0.2), value: viewModel.state) - .animation(.linear(duration: 0.1), value: viewModel.apiKeys) - .navigationTitle(L10n.apiKeys) - .onFirstAppear { - viewModel.send(.getAPIKeys) - } - .topBarTrailing { - if viewModel.apiKeys.isNotEmpty { - Button(L10n.add) { - showCreateAPIAlert = true - UIDevice.impact(.light) - } - .buttonStyle(.toolbarPill) - } - } - .alert(L10n.apiKeyCopied, isPresented: $showCopiedAlert) { - Button(L10n.ok, role: .cancel) {} - } message: { - Text(L10n.apiKeyCopiedMessage) - } - .confirmationDialog( - L10n.delete, - isPresented: $showDeleteConfirmation, - titleVisibility: .visible - ) { - Button(L10n.delete, role: .destructive) { - if let key = deleteAPI?.accessToken { - viewModel.send(.deleteAPIKey(key: key)) - } - } - Button(L10n.cancel, role: .cancel) {} - } message: { - Text(L10n.deleteAPIKeyMessage) - } - .alert(L10n.createAPIKey, isPresented: $showCreateAPIAlert) { - TextField(L10n.applicationName, text: $newAPIName) - Button(L10n.cancel, role: .cancel) {} - Button(L10n.save) { - viewModel.send(.createAPIKey(name: newAPIName)) - newAPIName = "" - } - } message: { - Text(L10n.createAPIKeyMessage) - } - } - - // MARK: - API Key Content - - private var contentView: some View { - List { - ListTitleSection( - L10n.apiKeysTitle, - description: L10n.apiKeysDescription - ) - - if viewModel.apiKeys.isNotEmpty { - ForEach(viewModel.apiKeys, id: \.accessToken) { apiKey in - APIKeysRow(apiKey: apiKey) { - UIPasteboard.general.string = apiKey.accessToken - showCopiedAlert = true - } onDelete: { - deleteAPI = apiKey - showDeleteConfirmation = true - } - } - } else { - Button(L10n.addAPIKey) { - showCreateAPIAlert = true - } - .foregroundStyle(Color.accentColor) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift b/jellypig iOS/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift deleted file mode 100644 index f0d313d3..00000000 --- a/jellypig iOS/Views/AdminDashboardView/APIKeyView/Components/APIKeysRow.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import JellyfinAPI -import SwiftUI - -extension APIKeysView { - - struct APIKeysRow: View { - - // MARK: - API Key Variables - - let apiKey: AuthenticationInfo - - // MARK: - API Key Actions - - let onSelect: () -> Void - let onDelete: () -> Void - - // MARK: - Row Content - - @ViewBuilder - private var rowContent: some View { - VStack(alignment: .leading, spacing: 4) { - Text(apiKey.appName ?? L10n.unknown) - .fontWeight(.semibold) - .lineLimit(2) - - Text(apiKey.accessToken ?? L10n.unknown) - .lineLimit(2) - - TextPairView( - L10n.dateCreated, - value: { - if let creationDate = apiKey.dateCreated { - Text(creationDate, format: .dateTime) - } else { - Text(L10n.unknown) - } - }() - ) - .monospacedDigit() - } - .font(.subheadline) - .multilineTextAlignment(.leading) - } - - // MARK: - Body - - var body: some View { - Button(action: onSelect) { - rowContent - } - .foregroundStyle(.primary, .secondary) - .swipeActions { - Button( - L10n.delete, - systemImage: "trash", - action: onDelete - ) - .tint(.red) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/StreamSection.swift b/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/StreamSection.swift deleted file mode 100644 index 0e7327ab..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/StreamSection.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ActiveSessionDetailView { - - struct StreamSection: View { - - let nowPlayingItem: BaseItemDto - let transcodingInfo: TranscodingInfo? - - // MARK: - Body - - var body: some View { - VStack(alignment: .leading) { - - // Create the Audio Codec Flow if the stream uses Audio - if let sourceAudioCodec = nowPlayingItem.mediaStreams?.first(where: { $0.type == .audio })?.codec { - getMediaComparison( - sourceComponent: sourceAudioCodec, - destinationComponent: transcodingInfo?.audioCodec ?? sourceAudioCodec - ) - } - - // Create the Video Codec Flow if the stream uses Video - if let sourceVideoCodec = nowPlayingItem.mediaStreams?.first(where: { $0.type == .video })?.codec { - getMediaComparison( - sourceComponent: sourceVideoCodec, - destinationComponent: transcodingInfo?.videoCodec ?? sourceVideoCodec - ) - } - - // Create the Container Flow if the stream has a Container - if let sourceContainer = nowPlayingItem.container { - getMediaComparison( - sourceComponent: sourceContainer, - destinationComponent: transcodingInfo?.container ?? sourceContainer - ) - } - } - } - - // MARK: - Transcoding Details - - @ViewBuilder - private func getMediaComparison(sourceComponent: String, destinationComponent: String) -> some View { - HStack { - Text(sourceComponent) - .frame(maxWidth: .infinity, alignment: .trailing) - - Image(systemName: (destinationComponent != sourceComponent) ? "shuffle" : "arrow.right") - .frame(maxWidth: .infinity, alignment: .center) - - Text(destinationComponent) - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(maxWidth: .infinity) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/TranscodeSection.swift b/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/TranscodeSection.swift deleted file mode 100644 index 3701a48b..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/Components/TranscodeSection.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ActiveSessionDetailView { - - struct TranscodeSection: View { - - let transcodeReasons: [TranscodeReason] - - // MARK: - Body - - var body: some View { - VStack(alignment: .center) { - - let transcodeIcons = Set(transcodeReasons.map(\.systemImage)).sorted() - - HStack { - ForEach(transcodeIcons, id: \.self) { icon in - Image(systemName: icon) - .foregroundStyle(.primary) - } - } - - Divider() - - ForEach(transcodeReasons, id: \.self) { reason in - Text(reason) - .multilineTextAlignment(.center) - .lineLimit(2) - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift b/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift deleted file mode 100644 index 9b9c8419..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI -import SwiftUI -import SwiftUIIntrospect - -struct ActiveSessionDetailView: View { - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @ObservedObject - var box: BindingBox - - // MARK: Create Idle Content View - - @ViewBuilder - private func idleContent(session: SessionInfoDto) -> some View { - List { - if let userID = session.userID { - let user = UserDto(id: userID, name: session.userName) - - AdminDashboardView.UserSection( - user: user, - lastActivityDate: session.lastActivityDate - ) { - router.route(to: \.userDetails, user) - } - } - - AdminDashboardView.DeviceSection( - client: session.client, - device: session.deviceName, - version: session.applicationVersion - ) - } - } - - // MARK: Create Session Content View - - @ViewBuilder - private func sessionContent( - session: SessionInfoDto, - nowPlayingItem: BaseItemDto, - playState: PlayerStateInfo - ) -> some View { - List { - - AdminDashboardView.MediaItemSection(item: nowPlayingItem) - - Section(L10n.progress) { - ActiveSessionsView.ProgressSection( - item: nowPlayingItem, - playState: playState, - transcodingInfo: session.transcodingInfo - ) - } - - if let userID = session.userID { - let user = UserDto(id: userID, name: session.userName) - - AdminDashboardView.UserSection( - user: user, - lastActivityDate: session.lastPlaybackCheckIn - ) { - router.route(to: \.userDetails, user) - } - } - - AdminDashboardView.DeviceSection( - client: session.client, - device: session.deviceName, - version: session.applicationVersion - ) - - // TODO: allow showing item stream details? - // TODO: don't show codec changes on direct play? - Section(L10n.streams) { - if let playMethodDisplayTitle = session.playMethodDisplayTitle { - TextPairView(leading: L10n.method, trailing: playMethodDisplayTitle) - } - - StreamSection( - nowPlayingItem: nowPlayingItem, - transcodingInfo: session.transcodingInfo - ) - } - - if let transcodeReasons = session.transcodingInfo?.transcodeReasons, transcodeReasons.isNotEmpty { - Section(L10n.transcodeReasons) { - TranscodeSection(transcodeReasons: transcodeReasons) - } - } - } - } - - var body: some View { - ZStack { - if let session = box.value { - if let nowPlayingItem = session.nowPlayingItem, let playState = session.playState { - sessionContent( - session: session, - nowPlayingItem: nowPlayingItem, - playState: playState - ) - } else { - idleContent(session: session) - } - } else { - Text(L10n.noSession) - } - } - .animation(.linear(duration: 0.2), value: box.value) - .navigationTitle(L10n.session) - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/ActiveSessionsView.swift b/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/ActiveSessionsView.swift deleted file mode 100644 index e49fdbca..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/ActiveSessionsView.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionVGrid -import Defaults -import JellyfinAPI -import SwiftUI - -// TODO: filter for streaming/inactive - -struct ActiveSessionsView: View { - - @EnvironmentObject - private var router: AdminDashboardCoordinator.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( - uniqueElements: viewModel.sessions.keys, - id: \.self, - 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() - } - } - .animation(.linear(duration: 0.2), value: viewModel.state) - .navigationTitle(L10n.sessions) - .onFirstAppear { - viewModel.send(.refreshSessions) - } - .onReceive(timer) { _ in - viewModel.send(.getSessions) - } - .refreshable { - viewModel.send(.refreshSessions) - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.gettingSessions) { - ProgressView() - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionProgressSection.swift b/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionProgressSection.swift deleted file mode 100644 index f05f61bf..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionProgressSection.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension ActiveSessionsView { - - struct ProgressSection: View { - - @Default(.accentColor) - private var accentColor - - let item: BaseItemDto - let playState: PlayerStateInfo - let transcodingInfo: TranscodingInfo? - - private var playbackPercentage: Double { - clamp(Double(playState.positionTicks ?? 0) / Double(item.runTimeTicks ?? 1), min: 0, max: 1) - } - - private var transcodingPercentage: Double? { - guard let c = transcodingInfo?.completionPercentage else { return nil } - return clamp(c / 100.0, min: 0, max: 1) - } - - @ViewBuilder - private var playbackInformation: some View { - HStack { - if playState.isPaused ?? false { - Image(systemName: "pause.fill") - .transition(.opacity.combined(with: .scale).animation(.bouncy)) - } else { - Image(systemName: "play.fill") - .transition(.opacity.combined(with: .scale).animation(.bouncy)) - } - - if let playMethod = playState.playMethod, playMethod == .transcode { - Text(playMethod) - } - - Spacer() - - HStack(spacing: 2) { - Text(playState.positionSeconds ?? 0, format: .runtime) - - Text("/") - - Text(item.runTimeSeconds, format: .runtime) - } - .monospacedDigit() - } - .font(.subheadline) - } - - var body: some View { - VStack { - ProgressView(value: playbackPercentage) - .progressViewStyle(.playback(secondaryProgress: transcodingPercentage)) - .frame(height: 5) - .foregroundStyle(.primary, .secondary, .orange) - - playbackInformation - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift b/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift deleted file mode 100644 index 85b7fe86..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension ActiveSessionsView { - - struct ActiveSessionRow: View { - - @CurrentDate - private var currentDate: Date - - @ObservedObject - private var box: BindingBox - - private let onSelect: () -> Void - - private var session: SessionInfoDto { - box.value ?? .init() - } - - init(box: BindingBox, onSelect action: @escaping () -> Void) { - self.box = box - self.onSelect = action - } - - @ViewBuilder - private var rowLeading: some View { - // TODO: better handling for different poster types - Group { - if let nowPlayingItem = session.nowPlayingItem { - if nowPlayingItem.type == .audio { - ZStack { - Color.clear - - ImageView(nowPlayingItem.squareImageSources(maxWidth: 60)) - .failure { - SystemImageContentView(systemName: nowPlayingItem.systemImage) - } - } - .squarePosterStyle() - .frame(width: 60, height: 60) - } else { - ZStack { - Color.clear - - ImageView(nowPlayingItem.portraitImageSources(maxWidth: 60)) - .failure { - SystemImageContentView(systemName: nowPlayingItem.systemImage) - } - } - .posterStyle(.portrait) - .frame(width: 60, height: 90) - } - } else { - ZStack { - session.device.clientColor - - Image(session.device.image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40) - } - .squarePosterStyle() - .frame(width: 60, height: 60) - } - } - .frame(width: 60, height: 90) - .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: .lastSeen) - ) - .id(currentDate) - .monospacedDigit() - } - } - .font(.subheadline) - } - - var body: some View { - ListRow(insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding)) { - rowLeading - } content: { - if let nowPlayingItem = session.nowPlayingItem, let playState = session.playState { - activeSessionDetails(nowPlayingItem, playState: playState) - } else { - idleSessionDetails - } - } - .onSelect(perform: onSelect) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/AdminDashboardView.swift b/jellypig iOS/Views/AdminDashboardView/AdminDashboardView.swift deleted file mode 100644 index 5fef5c71..00000000 --- a/jellypig iOS/Views/AdminDashboardView/AdminDashboardView.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct AdminDashboardView: View { - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - // MARK: - Body - - var body: some View { - List { - - ListTitleSection( - L10n.dashboard, - description: L10n.dashboardDescription - ) - - ChevronButton(L10n.sessions) { - router.route(to: \.activeSessions) - } - - Section(L10n.activity) { - ChevronButton(L10n.activity) { - router.route(to: \.activity) - } - ChevronButton(L10n.devices) { - router.route(to: \.devices) - } - ChevronButton(L10n.users) { - router.route(to: \.users) - } - } - - Section(L10n.advanced) { - - ChevronButton(L10n.apiKeys) { - router.route(to: \.apiKeys) - } - - ChevronButton(L10n.logs) { - router.route(to: \.serverLogs) - } - - ChevronButton(L10n.tasks) { - router.route(to: \.tasks) - } - } - } - .navigationTitle(L10n.dashboard) - .navigationBarTitleDisplayMode(.inline) - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/Components/DeviceSection.swift b/jellypig iOS/Views/AdminDashboardView/Components/DeviceSection.swift deleted file mode 100644 index a0ced98a..00000000 --- a/jellypig iOS/Views/AdminDashboardView/Components/DeviceSection.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AdminDashboardView { - - struct DeviceSection: View { - - let client: String? - let device: String? - let version: String? - - var body: some View { - Section(L10n.device) { - TextPairView( - leading: L10n.device, - trailing: device ?? L10n.unknown - ) - - TextPairView( - leading: L10n.client, - trailing: client ?? L10n.unknown - ) - - TextPairView( - leading: L10n.version, - trailing: version ?? L10n.unknown - ) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/Components/MediaItemSection.swift b/jellypig iOS/Views/AdminDashboardView/Components/MediaItemSection.swift deleted file mode 100644 index e5f408a3..00000000 --- a/jellypig iOS/Views/AdminDashboardView/Components/MediaItemSection.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AdminDashboardView { - - struct MediaItemSection: View { - - let item: BaseItemDto - - var body: 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) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/Components/UserSection.swift b/jellypig iOS/Views/AdminDashboardView/Components/UserSection.swift deleted file mode 100644 index ec524a41..00000000 --- a/jellypig iOS/Views/AdminDashboardView/Components/UserSection.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AdminDashboardView { - - struct UserSection: View { - - @CurrentDate - private var currentDate: Date - - private let user: UserDto - private let lastActivityDate: Date? - private let action: (() -> Void)? - - // MARK: - Initializer - - init(user: UserDto, lastActivityDate: Date? = nil, action: (() -> Void)? = nil) { - self.user = user - self.lastActivityDate = lastActivityDate - self.action = action - } - - // MARK: - Body - - var body: some View { - Section(L10n.user) { - profileView - TextPairView( - L10n.lastSeen, - value: Text(lastActivityDate, format: .lastSeen) - ) - .id(currentDate) - .monospacedDigit() - } - } - - // MARK: - Profile View - - private var profileView: some View { - if let onSelect = action { - SettingsView.UserProfileRow( - user: user - ) { - onSelect() - } - } else { - SettingsView.UserProfileRow( - user: user - ) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerActivity/ServerActivityDetailsView/ServerActivityDetailsView.swift b/jellypig iOS/Views/AdminDashboardView/ServerActivity/ServerActivityDetailsView/ServerActivityDetailsView.swift deleted file mode 100644 index ed654cef..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerActivity/ServerActivityDetailsView/ServerActivityDetailsView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct ServerActivityDetailsView: View { - - // MARK: - Environment Objects - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - // MARK: - Activity Log Entry Variable - - @StateObject - var viewModel: ServerActivityDetailViewModel - - // MARK: - Body - - var body: some View { - List { - /// Item (If Available) - if let item = viewModel.item { - AdminDashboardView.MediaItemSection(item: item) - } - - /// User (If Available) - if let user = viewModel.user { - AdminDashboardView.UserSection( - user: user, - lastActivityDate: viewModel.log.date - ) { - router.route(to: \.userDetails, user) - } - } - - /// Event Name & Overview - Section(L10n.overview) { - if let name = viewModel.log.name, name.isNotEmpty { - Text(name) - } - if let overview = viewModel.log.overview, overview.isNotEmpty { - Text(overview) - } else if let shortOverview = viewModel.log.shortOverview, shortOverview.isNotEmpty { - Text(shortOverview) - } - } - - /// Event Details - Section(L10n.details) { - if let severity = viewModel.log.severity { - TextPairView( - leading: L10n.level, - trailing: severity.displayTitle - ) - } - if let type = viewModel.log.type { - TextPairView( - leading: L10n.type, - trailing: type - ) - } - if let date = viewModel.log.date { - TextPairView( - leading: L10n.date, - trailing: date.formatted(date: .long, time: .shortened) - ) - } - } - } - .listStyle(.insetGrouped) - .navigationTitle( - L10n.activityLog - .localizedCapitalized - ) - .navigationBarTitleDisplayMode(.inline) - .onFirstAppear { - viewModel.send(.refresh) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift b/jellypig iOS/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift deleted file mode 100644 index 7e73d909..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ServerActivityView { - - struct LogEntry: View { - - // MARK: - Activity Log Entry Variable - - @StateObject - var viewModel: ServerActivityDetailViewModel - - // MARK: - Action Variable - - let action: () -> Void - - // MARK: - Body - - var body: some View { - ListRow { - userImage - } content: { - rowContent - .padding(.bottom, 8) - } - .onSelect(perform: action) - } - - // MARK: - User Image - - @ViewBuilder - private var userImage: some View { - let imageSource = viewModel.user?.profileImageSource(client: viewModel.userSession.client, maxWidth: 60) ?? .init() - - UserProfileImage( - userID: viewModel.log.userID ?? viewModel.userSession?.user.id, - source: imageSource - ) { - SystemImageContentView( - systemName: viewModel.user != nil ? "person.fill" : "gearshape.fill", - ratio: 0.5 - ) - } - .frame(width: 60, height: 60) - } - - // MARK: - User Image - - @ViewBuilder - private var rowContent: some View { - HStack { - VStack(alignment: .leading) { - /// Event Severity & Username / System - HStack(spacing: 8) { - Image(systemName: viewModel.log.severity?.systemImage ?? "questionmark.circle") - .foregroundStyle(viewModel.log.severity?.color ?? .gray) - - if viewModel.user != nil { - Text(viewModel.user?.name ?? L10n.unknown) - } else { - Text(L10n.system) - } - } - .font(.headline) - - /// Event Name - Text(viewModel.log.name ?? .emptyDash) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(2) - .multilineTextAlignment(.leading) - - Group { - if let eventDate = viewModel.log.date { - Text(eventDate.formatted(date: .abbreviated, time: .standard)) - } else { - Text(String.emptyTime) - } - } - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - Image(systemName: "chevron.right") - .padding() - .font(.body.weight(.regular)) - .foregroundStyle(.secondary) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift b/jellypig iOS/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift deleted file mode 100644 index 5eb17dca..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift +++ /dev/null @@ -1,198 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionVGrid -import JellyfinAPI -import SwiftUI - -struct ServerActivityView: View { - - // MARK: - Environment Objects - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - // MARK: - State Objects - - @StateObject - private var viewModel = ServerActivityViewModel() - - // MARK: - Dialog States - - @State - private var isDatePickerShowing: Bool = false - @State - private var tempDate: Date? - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - contentView - case let .error(error): - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - case .initial, .refreshing: - DelayedProgressView() - } - } - .animation(.linear(duration: 0.2), value: viewModel.state) - .navigationTitle(L10n.activity) - .navigationBarTitleDisplayMode(.inline) - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.gettingNextPage) - ) { - Section(L10n.filters) { - startDateButton - userFilterButton - } - } - .onFirstAppear { - viewModel.send(.refresh) - } - .sheet(isPresented: $isDatePickerShowing, onDismiss: { isDatePickerShowing = false }) { - startDatePickerSheet - } - } - - // MARK: - Content View - - @ViewBuilder - private var contentView: some View { - if viewModel.elements.isEmpty { - Text(L10n.none) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .listRowSeparator(.hidden) - .listRowInsets(.zero) - } else { - CollectionVGrid( - uniqueElements: viewModel.elements, - id: \.unwrappedIDHashOrZero, - layout: .columns(1) - ) { log in - - let user = viewModel.users.first( - property: \.id, - equalTo: log.userID - ) - - let logViewModel = ServerActivityDetailViewModel( - log: log, - user: user - ) - - LogEntry(viewModel: logViewModel) { - router.route(to: \.activityDetails, logViewModel) - } - } - .onReachedBottomEdge(offset: .offset(300)) { - viewModel.send(.getNextPage) - } - .frame(maxWidth: .infinity) - } - } - - // MARK: - User Filter Button - - @ViewBuilder - private var userFilterButton: some View { - Menu( - L10n.type, - systemImage: viewModel.hasUserId == true ? "person.fill" : - viewModel.hasUserId == false ? "gearshape.fill" : "line.3.horizontal" - ) { - Picker(L10n.type, selection: $viewModel.hasUserId) { - Section { - Label( - L10n.all, - systemImage: "line.3.horizontal" - ) - .tag(nil as Bool?) - } - - Label( - L10n.users, - systemImage: "person" - ) - .tag(true as Bool?) - - Label( - L10n.system, - systemImage: "gearshape" - ) - .tag(false as Bool?) - } - } - } - - // MARK: - Start Date Button - - @ViewBuilder - private var startDateButton: some View { - Button(L10n.startDate, systemImage: "calendar") { - if let minDate = viewModel.minDate { - tempDate = minDate - } else { - tempDate = .now - } - isDatePickerShowing = true - } - } - - // MARK: - Start Date Picker Sheet - - @ViewBuilder - private var startDatePickerSheet: some View { - NavigationView { - List { - Section { - DatePicker( - L10n.date, - selection: $tempDate.coalesce(.now), - in: ...Date.now, - displayedComponents: .date - ) - .datePickerStyle(.graphical) - .labelsHidden() - } - - /// Reset button to remove the filter - if viewModel.minDate != nil { - Section { - ListRowButton(L10n.reset, role: .destructive) { - viewModel.minDate = nil - isDatePickerShowing = false - } - } footer: { - Text(L10n.resetFilterFooter) - } - } - } - .navigationTitle(L10n.startDate.localizedCapitalized) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - isDatePickerShowing = false - } - .topBarTrailing { - let startOfDay = Calendar.current - .startOfDay(for: tempDate ?? .now) - - Button(L10n.save) { - viewModel.minDate = startOfDay - isDatePickerShowing = false - } - .buttonStyle(.toolbarPill) - .disabled(viewModel.minDate != nil && startOfDay == viewModel.minDate) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/Components/Sections/CompatibilitiesSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/Components/Sections/CompatibilitiesSection.swift deleted file mode 100644 index 91876d91..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/Components/Sections/CompatibilitiesSection.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension DeviceDetailsView { - struct CapabilitiesSection: View { - var device: DeviceInfoDto - - var body: some View { - Section(L10n.capabilities) { - if let supportsMediaControl = device.capabilities?.isSupportsMediaControl { - TextPairView(leading: L10n.supportsMediaControl, trailing: supportsMediaControl ? L10n.yes : L10n.no) - } - - if let supportsPersistentIdentifier = device.capabilities?.isSupportsPersistentIdentifier { - TextPairView(leading: L10n.supportsPersistentIdentifier, trailing: supportsPersistentIdentifier ? L10n.yes : L10n.no) - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/Components/Sections/CustomDeviceNameSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/Components/Sections/CustomDeviceNameSection.swift deleted file mode 100644 index 540a10de..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/Components/Sections/CustomDeviceNameSection.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension DeviceDetailsView { - struct CustomDeviceNameSection: View { - @Binding - var customName: String - - // MARK: - Body - - var body: some View { - Section(L10n.name) { - TextField( - L10n.name, - text: $customName - ) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/DeviceDetailsView.swift b/jellypig iOS/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/DeviceDetailsView.swift deleted file mode 100644 index 750e9dee..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DeviceDetailsView/DeviceDetailsView.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct DeviceDetailsView: View { - - // MARK: - Current Date - - @CurrentDate - private var currentDate: Date - - // MARK: - State & Environment Objects - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @StateObject - private var viewModel: DeviceDetailViewModel - - // MARK: - Custom Name Variable - - @State - private var temporaryCustomName: String - - // MARK: - Dialog State - - @State - private var isPresentingSuccess: Bool = false - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(device: DeviceInfoDto) { - _viewModel = StateObject(wrappedValue: DeviceDetailViewModel(device: device)) - self.temporaryCustomName = device.customName ?? device.name ?? "" - } - - // MARK: - Body - - var body: some View { - List { - if let userID = viewModel.device.lastUserID, - let userName = viewModel.device.lastUserName - { - - let user = UserDto(id: userID, name: userName) - - AdminDashboardView.UserSection( - user: user, - lastActivityDate: viewModel.device.dateLastActivity - ) { - router.route(to: \.userDetails, user) - } - } - - CustomDeviceNameSection(customName: $temporaryCustomName) - - AdminDashboardView.DeviceSection( - client: viewModel.device.appName, - device: viewModel.device.name, - version: viewModel.device.appVersion - ) - - CapabilitiesSection(device: viewModel.device) - } - .navigationTitle(L10n.device) - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .setCustomName: - UIDevice.feedback(.success) - isPresentingSuccess = true - } - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.updating) { - ProgressView() - } - Button(L10n.save) { - UIDevice.impact(.light) - if viewModel.device.id != nil { - viewModel.send(.setCustomName(temporaryCustomName)) - } - } - .buttonStyle(.toolbarPill) - .disabled(temporaryCustomName == viewModel.device.customName) - } - .alert( - L10n.success.text, - isPresented: $isPresentingSuccess - ) { - Button(L10n.dismiss, role: .cancel) - } message: { - Text(L10n.customDeviceNameSaved(temporaryCustomName)) - } - .errorMessage($error) - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift deleted file mode 100644 index f20541e1..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import JellyfinAPI -import SwiftUI - -extension DevicesView { - - struct DeviceRow: View { - - @Default(.accentColor) - private var accentColor - - // MARK: - Environment Variables - - @Environment(\.colorScheme) - private var colorScheme - @Environment(\.isEditing) - private var isEditing - @Environment(\.isSelected) - private var isSelected - - @CurrentDate - private var currentDate: Date - - // MARK: - Properties - - let device: DeviceInfoDto - let onSelect: () -> Void - let onDelete: (() -> Void)? - - // MARK: - Initializer - - init( - device: DeviceInfoDto, - onSelect: @escaping () -> Void, - onDelete: (() -> Void)? = nil - ) { - self.device = device - self.onSelect = onSelect - self.onDelete = onDelete - } - - // MARK: - Label Styling - - private var labelForegroundStyle: some ShapeStyle { - guard isEditing else { return .primary } - return isSelected ? .primary : .secondary - } - - // MARK: - Device Image View - - @ViewBuilder - private var deviceImage: some View { - ZStack { - device.type.clientColor - - Image(device.type.image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40) - - if isEditing { - Color.black - .opacity(isSelected ? 0 : 0.5) - } - } - .squarePosterStyle() - .posterShadow() - .frame(width: 60, height: 60) - } - - // MARK: - Row Content - - @ViewBuilder - private var rowContent: some View { - HStack { - VStack(alignment: .leading) { - Text(device.customName ?? device.name ?? L10n.unknown) - .font(.headline) - .lineLimit(2) - .multilineTextAlignment(.leading) - - TextPairView( - leading: L10n.user, - trailing: device.lastUserName ?? L10n.unknown - ) - .lineLimit(1) - - TextPairView( - leading: L10n.client, - trailing: device.appName ?? L10n.unknown - ) - .lineLimit(1) - - TextPairView( - L10n.lastSeen, - value: Text(device.dateLastActivity, format: .lastSeen) - ) - .id(currentDate) - .lineLimit(1) - .monospacedDigit() - } - .font(.subheadline) - .foregroundStyle(labelForegroundStyle, .secondary) - - Spacer() - - ListRowCheckbox() - } - } - - // MARK: - Body - - var body: some View { - ListRow { - deviceImage - } content: { - rowContent - } - .onSelect(perform: onSelect) - .isSeparatorVisible(false) - .swipeActions { - if let onDelete = onDelete { - Button( - L10n.delete, - systemImage: "trash", - action: onDelete - ) - .tint(.red) - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DevicesView/DevicesView.swift b/jellypig iOS/Views/AdminDashboardView/ServerDevices/DevicesView/DevicesView.swift deleted file mode 100644 index cd5e933f..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerDevices/DevicesView/DevicesView.swift +++ /dev/null @@ -1,218 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import OrderedCollections -import SwiftUI - -struct DevicesView: View { - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @State - private var isPresentingDeleteSelectionConfirmation = false - @State - private var isPresentingDeleteConfirmation = false - @State - private var isPresentingSelfDeleteError = false - @State - private var selectedDevices: Set = [] - @State - private var isEditing: Bool = false - - @StateObject - private var viewModel = DevicesViewModel() - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - deviceListView - case let .error(error): - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - case .initial: - DelayedProgressView() - } - } - .animation(.linear(duration: 0.2), value: viewModel.state) - .navigationTitle(L10n.devices) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isEditing) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - if isEditing { - navigationBarSelectView - } - } - ToolbarItem(placement: .navigationBarTrailing) { - if viewModel.devices.isNotEmpty { - navigationBarEditView - } - } - ToolbarItem(placement: .bottomBar) { - if isEditing { - Button(L10n.delete) { - isPresentingDeleteSelectionConfirmation = true - } - .buttonStyle(.toolbarPill(.red)) - .disabled(selectedDevices.isEmpty) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - .onFirstAppear { - viewModel.send(.refresh) - } - .confirmationDialog( - L10n.deleteSelectedDevices, - isPresented: $isPresentingDeleteSelectionConfirmation, - titleVisibility: .visible - ) { - deleteSelectedDevicesConfirmationActions - } message: { - Text(L10n.deleteSelectionDevicesWarning) - } - .confirmationDialog( - L10n.deleteDevice, - isPresented: $isPresentingDeleteConfirmation, - titleVisibility: .visible - ) { - deleteDeviceConfirmationActions - } message: { - Text(L10n.deleteDeviceWarning) - } - .alert(L10n.deleteDeviceFailed, isPresented: $isPresentingSelfDeleteError) { - Button(L10n.ok, role: .cancel) {} - } message: { - Text(L10n.deleteDeviceSelfDeletion(viewModel.userSession.client.configuration.deviceName)) - } - } - - // MARK: - Device List View - - @ViewBuilder - private var deviceListView: some View { - List { - InsetGroupedListHeader( - L10n.devices, - description: L10n.allDevicesDescription - ) { - UIApplication.shared.open(.jellyfinDocsDevices) - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.vertical, 24) - - if viewModel.devices.isEmpty { - Text(L10n.none) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .listRowSeparator(.hidden) - .listRowInsets(.zero) - } else { - ForEach(viewModel.devices, id: \.self) { device in - DeviceRow(device: device) { - guard let id = device.id else { return } - - if isEditing { - if selectedDevices.contains(id) { - selectedDevices.remove(id) - } else { - selectedDevices.insert(id) - } - } else { - router.route(to: \.deviceDetails, device) - } - } onDelete: { - guard let id = device.id else { return } - - selectedDevices.removeAll() - selectedDevices.insert(id) - isPresentingDeleteConfirmation = true - } - .environment(\.isEditing, isEditing) - .environment(\.isSelected, selectedDevices.contains(device.id ?? "")) - .listRowInsets(.edgeInsets) - } - } - } - .listStyle(.plain) - } - - // MARK: - Navigation Bar Edit Content - - @ViewBuilder - private var navigationBarEditView: some View { - if viewModel.backgroundStates.contains(.refreshing) { - ProgressView() - } else { - Button(isEditing ? L10n.cancel : L10n.edit) { - isEditing.toggle() - UIDevice.impact(.light) - if !isEditing { - selectedDevices.removeAll() - } - } - .buttonStyle(.toolbarPill) - } - } - - // MARK: - Navigation Bar Select/Remove All Content - - @ViewBuilder - private var navigationBarSelectView: some View { - let isAllSelected: Bool = selectedDevices.count == viewModel.devices.count - - Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { - if isAllSelected { - selectedDevices = [] - } else { - selectedDevices = Set(viewModel.devices.compactMap(\.id)) - } - } - .buttonStyle(.toolbarPill) - .disabled(!isEditing) - } - - // MARK: - Delete Selected Devices Confirmation Actions - - @ViewBuilder - private var deleteSelectedDevicesConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.confirm, role: .destructive) { - viewModel.send(.delete(ids: Array(selectedDevices))) - isEditing = false - selectedDevices.removeAll() - } - } - - // MARK: - Delete Device Confirmation Actions - - @ViewBuilder - private var deleteDeviceConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.delete, role: .destructive) { - if let deviceToDelete = selectedDevices.first, selectedDevices.count == 1 { - if deviceToDelete == viewModel.userSession.client.configuration.deviceID { - isPresentingSelfDeleteError = true - } else { - viewModel.send(.delete(ids: [deviceToDelete])) - selectedDevices.removeAll() - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift b/jellypig iOS/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift deleted file mode 100644 index e24e050c..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerLogsView/ServerLogsView.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// 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) 2025 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 { - ListTitleSection( - L10n.serverLogs, - description: L10n.logsDescription - ) { - UIApplication.shared.open(URL(string: "https://jellyfin.org/docs/general/administration/troubleshooting")!) - } - ForEach(viewModel.logs, id: \.self) { log in - Button { - let request = Paths.getLogFile(name: log.name!) - let url = viewModel.userSession.client.fullURL(with: request, queryAPIKey: true)! - - UIApplication.shared.open(url) - } label: { - HStack { - VStack(alignment: .leading) { - Text(log.name ?? .emptyDash) - - if let modifiedDate = log.dateModified { - Text(modifiedDate, format: .dateTime) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - Image(systemName: "arrow.up.forward") - .font(.body.weight(.regular)) - .foregroundColor(.secondary) - } - } - .foregroundStyle(.primary, .secondary) - } - } - } - - @ViewBuilder - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.getLogs) - } - } - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - contentView - case let .error(error): - errorView(with: error) - case .initial: - DelayedProgressView() - } - } - .animation(.linear(duration: 0.2), value: viewModel.state) - .navigationBarTitle(L10n.serverLogs) - .onFirstAppear { - viewModel.send(.getLogs) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/AddTaskTriggerView.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/AddTaskTriggerView.swift deleted file mode 100644 index 744fd5d0..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/AddTaskTriggerView.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct AddTaskTriggerView: View { - - @Environment(\.dismiss) - private var dismiss - - @ObservedObject - var observer: ServerTaskObserver - - @State - private var isPresentingNotSaved = false - @State - private var taskTriggerInfo: TaskTriggerInfo - - static let defaultTimeOfDayTicks = 0 - static let defaultDayOfWeek: DayOfWeek = .sunday - static let defaultIntervalTicks = 36_000_000_000 - private let emptyTaskTriggerInfo: TaskTriggerInfo - - private var hasUnsavedChanges: Bool { - taskTriggerInfo != emptyTaskTriggerInfo - } - - private var isDuplicate: Bool { - observer.task.triggers?.contains(where: { $0 == taskTriggerInfo }) ?? false - } - - // MARK: - Init - - init(observer: ServerTaskObserver) { - self.observer = observer - - let newTrigger = TaskTriggerInfo( - dayOfWeek: nil, - intervalTicks: nil, - maxRuntimeTicks: nil, - timeOfDayTicks: nil, - type: TaskTriggerType.startup - ) - - _taskTriggerInfo = State(initialValue: newTrigger) - self.emptyTaskTriggerInfo = newTrigger - } - - // MARK: - View for TaskTriggerType.daily - - @ViewBuilder - private var dailyView: some View { - TimeRow(taskTriggerInfo: $taskTriggerInfo) - } - - // MARK: - View for TaskTriggerType.weekly - - @ViewBuilder - private var weeklyView: some View { - DayOfWeekRow(taskTriggerInfo: $taskTriggerInfo) - TimeRow(taskTriggerInfo: $taskTriggerInfo) - } - - // MARK: - View for TaskTriggerType.interval - - @ViewBuilder - private var intervalView: some View { - IntervalRow(taskTriggerInfo: $taskTriggerInfo) - } - - // MARK: - Body - - var body: some View { - Form { - Section { - TriggerTypeRow(taskTriggerInfo: $taskTriggerInfo) - - if let taskType = taskTriggerInfo.type { - if taskType == TaskTriggerType.daily { - dailyView - } else if taskType == TaskTriggerType.weekly { - weeklyView - } else if taskType == TaskTriggerType.interval { - intervalView - } - } - } footer: { - if isDuplicate { - Label(L10n.triggerAlreadyExists, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } - } - - TimeLimitSection(taskTriggerInfo: $taskTriggerInfo) - } - .animation(.linear(duration: 0.2), value: isDuplicate) - .animation(.linear(duration: 0.2), value: taskTriggerInfo.type) - .interactiveDismissDisabled(true) - .navigationTitle(L10n.addTrigger) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - if hasUnsavedChanges { - isPresentingNotSaved = true - } else { - dismiss() - } - } - .topBarTrailing { - Button(L10n.save) { - - UIDevice.impact(.light) - - observer.send(.addTrigger(taskTriggerInfo)) - dismiss() - } - .buttonStyle(.toolbarPill) - .disabled(isDuplicate) - } - .alert(L10n.unsavedChangesMessage, isPresented: $isPresentingNotSaved) { - Button(L10n.close, role: .destructive) { - dismiss() - } - Button(L10n.cancel, role: .cancel) { - isPresentingNotSaved = false - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/DayOfWeekRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/DayOfWeekRow.swift deleted file mode 100644 index f4b296b8..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/DayOfWeekRow.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddTaskTriggerView { - - struct DayOfWeekRow: View { - - @Binding - var taskTriggerInfo: TaskTriggerInfo - - // MARK: - Body - - var body: some View { - Picker( - L10n.dayOfWeek, - selection: Binding( - get: { taskTriggerInfo.dayOfWeek ?? defaultDayOfWeek }, - set: { taskTriggerInfo.dayOfWeek = $0 } - ) - ) { - ForEach(DayOfWeek.allCases, id: \.self) { day in - Text(day.displayTitle ?? L10n.unknown) - .tag(day) - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow.swift deleted file mode 100644 index 2464ba46..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/IntervalRow.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddTaskTriggerView { - - struct IntervalRow: View { - - @Binding - private var taskTriggerInfo: TaskTriggerInfo - - @State - private var tempInterval: Int? - - // MARK: - Init - - init(taskTriggerInfo: Binding) { - self._taskTriggerInfo = taskTriggerInfo - _tempInterval = State(initialValue: Int(ServerTicks(taskTriggerInfo.wrappedValue.intervalTicks).minutes)) - } - - // MARK: - Body - - var body: some View { - ChevronButton( - L10n.every, - subtitle: ServerTicks( - taskTriggerInfo.intervalTicks - ).seconds.formatted(.hourMinute), - description: L10n.taskTriggerInterval - ) { - TextField( - L10n.minutes, - value: $tempInterval, - format: .number - ) - .keyboardType(.numberPad) - } onSave: { - if tempInterval != nil && tempInterval != 0 { - taskTriggerInfo.intervalTicks = ServerTicks(minutes: tempInterval).ticks - } else { - taskTriggerInfo.intervalTicks = nil - } - } onCancel: { - if let intervalTicks = taskTriggerInfo.intervalTicks { - tempInterval = Int(ServerTicks(intervalTicks).minutes) - } else { - tempInterval = nil - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection.swift deleted file mode 100644 index 83bad0a0..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeLimitSection.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddTaskTriggerView { - - struct TimeLimitSection: View { - - @Binding - private var taskTriggerInfo: TaskTriggerInfo - - @State - private var tempTimeLimit: Int? - - // MARK: - Init - - init(taskTriggerInfo: Binding) { - self._taskTriggerInfo = taskTriggerInfo - _tempTimeLimit = State(initialValue: Int(ServerTicks(taskTriggerInfo.wrappedValue.maxRuntimeTicks).hours)) - } - - // MARK: - Body - - var body: some View { - Section { - ChevronButton( - L10n.timeLimit, - subtitle: subtitleString, - description: L10n.taskTriggerTimeLimit - ) { - TextField( - L10n.hours, - value: $tempTimeLimit, - format: .number - ) - .keyboardType(.numberPad) - } onSave: { - if tempTimeLimit != nil && tempTimeLimit != 0 { - taskTriggerInfo.maxRuntimeTicks = ServerTicks(hours: tempTimeLimit).ticks - } else { - taskTriggerInfo.maxRuntimeTicks = nil - } - } onCancel: { - if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { - tempTimeLimit = Int(ServerTicks(maxRuntimeTicks).hours) - } else { - tempTimeLimit = nil - } - } - } - } - - // MARK: - Create Subtitle String - - private var subtitleString: String { - if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { - ServerTicks(maxRuntimeTicks).seconds.formatted(.hourMinute) - } else { - L10n.none - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow.swift deleted file mode 100644 index 5d630bf5..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TimeRow.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddTaskTriggerView { - - struct TimeRow: View { - - @Binding - var taskTriggerInfo: TaskTriggerInfo - - var body: some View { - DatePicker( - L10n.time, - selection: Binding( - get: { - ServerTicks( - taskTriggerInfo.timeOfDayTicks ?? defaultTimeOfDayTicks - ).date - }, - set: { date in - taskTriggerInfo.timeOfDayTicks = ServerTicks(date: date).ticks - } - ), - displayedComponents: .hourAndMinute - ) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow.swift deleted file mode 100644 index 7cbf60f2..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/AddTaskTriggerView/Components/TriggerTypeRow.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddTaskTriggerView { - - struct TriggerTypeRow: View { - - @Binding - var taskTriggerInfo: TaskTriggerInfo - - var body: some View { - Picker( - L10n.type, - selection: $taskTriggerInfo.type - ) { - ForEach(TaskTriggerType.allCases, id: \.self) { type in - Text(type.displayTitle) - .tag(type as TaskTriggerType?) - } - } - .onChange(of: taskTriggerInfo.type) { newType in - resetValuesForNewType(newType: newType) - } - } - - private func resetValuesForNewType(newType: TaskTriggerType?) { - taskTriggerInfo.type = newType - let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks - - switch newType { - case .daily: - taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks - taskTriggerInfo.dayOfWeek = nil - taskTriggerInfo.intervalTicks = nil - case .weekly: - taskTriggerInfo.timeOfDayTicks = defaultTimeOfDayTicks - taskTriggerInfo.dayOfWeek = defaultDayOfWeek - taskTriggerInfo.intervalTicks = nil - case .interval: - taskTriggerInfo.intervalTicks = defaultIntervalTicks - taskTriggerInfo.timeOfDayTicks = nil - taskTriggerInfo.dayOfWeek = nil - case .startup: - taskTriggerInfo.timeOfDayTicks = nil - taskTriggerInfo.dayOfWeek = nil - taskTriggerInfo.intervalTicks = nil - default: - taskTriggerInfo.timeOfDayTicks = nil - taskTriggerInfo.dayOfWeek = nil - taskTriggerInfo.intervalTicks = nil - } - - taskTriggerInfo.maxRuntimeTicks = maxRuntimeTicks - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/DetailsSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/DetailsSection.swift deleted file mode 100644 index 7cfafd7c..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/DetailsSection.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension EditServerTaskView { - - struct DetailsSection: View { - - let category: String - - var body: some View { - Section(L10n.details) { - TextPairView(leading: L10n.category, trailing: category) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection.swift deleted file mode 100644 index 59086c4b..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastErrorSection.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension EditServerTaskView { - - struct LastErrorSection: View { - - let message: String - - var body: some View { - Section(L10n.errorDetails) { - Text(message) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastRunSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastRunSection.swift deleted file mode 100644 index 25ad2daa..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/LastRunSection.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension EditServerTaskView { - - struct LastRunSection: View { - - @CurrentDate - private var currentDate: Date - - let status: TaskCompletionStatus - let endTime: Date - - var body: some View { - Section(L10n.lastRun) { - - TextPairView( - leading: L10n.status, - trailing: status.displayTitle - ) - - TextPairView( - L10n.executed, - value: Text(endTime, format: .lastSeen) - ) - .id(currentDate) - .monospacedDigit() - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift deleted file mode 100644 index bf9de7f6..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/ServerTaskProgressSection.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -// -// 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 EditServerTaskView { - - struct ProgressSection: View { - - @ObservedObject - var observer: ServerTaskObserver - - var body: some View { - if observer.task.state == .running || observer.task.state == .cancelling { - Section(L10n.progress) { - if let status = observer.task.state { - TextPairView( - leading: L10n.status, - trailing: status.displayTitle - ) - } - - if let currentProgressPercentage = observer.task.currentProgressPercentage { - TextPairView( - L10n.progress, - value: Text("\(currentProgressPercentage / 100, format: .percent.precision(.fractionLength(1)))") - ) - .monospacedDigit() - } - - Button { - observer.send(.stop) - } label: { - HStack { - Text(L10n.stop) - - Spacer() - - Image(systemName: "stop.fill") - } - } - .foregroundStyle(.red) - } - } else { - Button(L10n.run) { - observer.send(.start) - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/TriggersSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/TriggersSection.swift deleted file mode 100644 index cc240750..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/Sections/TriggersSection.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension EditServerTaskView { - - struct TriggersSection: View { - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @ObservedObject - var observer: ServerTaskObserver - - @State - private var isPresentingDeleteConfirmation: Bool = false - @State - private var selectedTrigger: TaskTriggerInfo? - - var body: some View { - Section(L10n.triggers) { - if let triggers = observer.task.triggers, triggers.isNotEmpty { - ForEach(triggers, id: \.self) { trigger in - TriggerRow(taskTriggerInfo: trigger) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button { - selectedTrigger = trigger - isPresentingDeleteConfirmation = true - } label: { - Label(L10n.delete, systemImage: "trash") - } - .tint(.red) - } - } - } else { - Button(L10n.addTrigger) { - router.route(to: \.addServerTaskTrigger, observer) - } - } - } - .confirmationDialog( - L10n.deleteTrigger, - isPresented: $isPresentingDeleteConfirmation, - titleVisibility: .visible - ) { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.delete, role: .destructive) { - if let selectedTrigger { - observer.send(.removeTrigger(selectedTrigger)) - } - } - } message: { - Text(L10n.deleteTriggerConfirmationMessage) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/TriggerRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/TriggerRow.swift deleted file mode 100644 index 3d705903..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/Components/TriggerRow.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import Stinsen -import SwiftUI - -extension EditServerTaskView { - - struct TriggerRow: View { - - let taskTriggerInfo: TaskTriggerInfo - - // MARK: - Body - - var body: some View { - HStack { - VStack(alignment: .leading) { - - Text(triggerDisplayText(for: taskTriggerInfo.type)) - .fontWeight(.semibold) - - Group { - if let maxRuntimeTicks = taskTriggerInfo.maxRuntimeTicks { - Text( - L10n.timeLimitLabelWithValue( - ServerTicks(maxRuntimeTicks) - .seconds.formatted(.hourMinute) - ) - ) - } else { - Text(L10n.noRuntimeLimit) - } - } - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - - Image(systemName: (taskTriggerInfo.type ?? .startup).systemImage) - .backport - .fontWeight(.bold) - .foregroundStyle(.secondary) - } - } - - // MARK: - Trigger Display Text - - private func triggerDisplayText(for triggerType: TaskTriggerType?) -> String { - - guard let triggerType else { return L10n.unknown } - - switch triggerType { - case .daily: - if let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks { - return L10n.itemAtItem( - triggerType.displayTitle, - ServerTicks(timeOfDayTicks) - .date.formatted(date: .omitted, time: .shortened) - ) - } - case .weekly: - if let dayOfWeek = taskTriggerInfo.dayOfWeek, - let timeOfDayTicks = taskTriggerInfo.timeOfDayTicks - { - return L10n.itemAtItem( - dayOfWeek.rawValue.capitalized, - ServerTicks(timeOfDayTicks) - .date.formatted(date: .omitted, time: .shortened) - ) - } - case .interval: - if let intervalTicks = taskTriggerInfo.intervalTicks { - return L10n.everyInterval( - ServerTicks(intervalTicks) - .seconds.formatted(.hourMinute) - ) - } - case .startup: - return triggerType.displayTitle - } - - return L10n.unknown - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/EditServerTaskView.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/EditServerTaskView.swift deleted file mode 100644 index 1e4c1102..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/EditServerTaskView/EditServerTaskView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -struct EditServerTaskView: View { - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @ObservedObject - var observer: ServerTaskObserver - - // MARK: - Trigger Variables - - @State - private var selectedTrigger: TaskTriggerInfo? - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Body - - var body: some View { - List { - ListTitleSection( - observer.task.name ?? L10n.unknown, - description: observer.task.description - ) - - ProgressSection(observer: observer) - - if let category = observer.task.category { - DetailsSection(category: category) - } - - if let lastExecutionResult = observer.task.lastExecutionResult { - if let status = lastExecutionResult.status, let endTime = lastExecutionResult.endTimeUtc { - LastRunSection(status: status, endTime: endTime) - } - - if let errorMessage = lastExecutionResult.errorMessage { - LastErrorSection(message: errorMessage) - } - } - - TriggersSection(observer: observer) - } - .animation(.linear(duration: 0.2), value: observer.state) - .animation(.linear(duration: 0.1), value: observer.task.state) - .animation(.linear(duration: 0.1), value: observer.task.triggers) - .navigationTitle(L10n.task) - .topBarTrailing { - - if observer.backgroundStates.contains(.updatingTriggers) { - ProgressView() - } - - if let triggers = observer.task.triggers, triggers.isNotEmpty { - Button(L10n.add) { - UIDevice.impact(.light) - router.route(to: \.addServerTaskTrigger, observer) - } - .buttonStyle(.toolbarPill) - } - } - .onReceive(observer.events) { event in - switch event { - case let .error(eventError): - error = eventError - } - } - .errorMessage($error) - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/DestructiveServerTask.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/DestructiveServerTask.swift deleted file mode 100644 index ced1c6a8..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/DestructiveServerTask.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension ServerTasksView { - - struct DestructiveServerTask: View { - - @State - private var isPresented: Bool = false - - let title: String - let systemName: String - let message: String - let action: () -> Void - - // MARK: - Body - - var body: some View { - Button(role: .destructive) { - isPresented = true - } label: { - HStack { - Text(title) - .fontWeight(.semibold) - - Spacer() - - Image(systemName: systemName) - .backport - .fontWeight(.bold) - } - } - .confirmationDialog( - title, - isPresented: $isPresented, - titleVisibility: .visible - ) { - Button(title, role: .destructive, action: action) - } message: { - Text(message) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/ServerTaskRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/ServerTaskRow.swift deleted file mode 100644 index 11f75273..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/ServerTasksView/Components/ServerTaskRow.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import Stinsen -import SwiftUI - -extension ServerTasksView { - - struct ServerTaskRow: View { - - @CurrentDate - private var currentDate: Date - - @EnvironmentObject - private var router: AdminDashboardCoordinator.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 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() - - if observer.state == .running { - ProgressView(value: (observer.task.currentProgressPercentage ?? 0) / 100) - .progressViewStyle(.gauge) - .transition(.opacity.combined(with: .scale).animation(.bouncy)) - .frame(width: 25, height: 25) - } - - Image(systemName: "chevron.right") - .font(.body.weight(.regular)) - .foregroundStyle(.secondary) - } - } - .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: \.editServerTask, observer) - } - } message: { - if let description = observer.task.description { - Text(description) - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerTasks/ServerTasksView/ServerTasksView.swift b/jellypig iOS/Views/AdminDashboardView/ServerTasks/ServerTasksView/ServerTasksView.swift deleted file mode 100644 index 61ff4227..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerTasks/ServerTasksView/ServerTasksView.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -// TODO: refactor after socket implementation - -struct ServerTasksView: View { - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @StateObject - private var viewModel = ServerTasksViewModel() - - private let timer = Timer.publish(every: 5, on: .main, in: .common) - .autoconnect() - - // MARK: - Server Function Buttons - - @ViewBuilder - private var serverFunctions: some View { - DestructiveServerTask( - title: L10n.restartServer, - systemName: "arrow.clockwise", - message: L10n.restartWarning - ) { - viewModel.send(.restartApplication) - } - - DestructiveServerTask( - title: L10n.shutdownServer, - systemName: "power", - message: L10n.shutdownWarning - ) { - viewModel.send(.shutdownApplication) - } - } - - // MARK: - Body - - @ViewBuilder - private var contentView: some View { - List { - - ListTitleSection( - L10n.tasks, - description: L10n.tasksDescription - ) { - UIApplication.shared.open(.jellyfinDocsTasks) - } - - Section(L10n.server) { - serverFunctions - } - - ForEach(viewModel.tasks.keys, id: \.self) { category in - Section(category) { - ForEach(viewModel.tasks[category] ?? []) { task in - ServerTaskRow(observer: task) - } - } - } - } - } - - @ViewBuilder - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.refreshTasks) - } - } - - var body: some View { - ZStack { - Color.clear - - switch viewModel.state { - case .content: - contentView - case let .error(error): - errorView(with: error) - case .initial: - DelayedProgressView() - } - } - .animation(.linear(duration: 0.2), value: viewModel.state) - .navigationTitle(L10n.tasks) - .onFirstAppear { - viewModel.send(.refreshTasks) - } - .onReceive(timer) { _ in - viewModel.send(.getTasks) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/AddServerUserView/AddServerUserView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/AddServerUserView/AddServerUserView.swift deleted file mode 100644 index eb72520e..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/AddServerUserView/AddServerUserView.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct AddServerUserView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Focus Fields - - private enum Field: Hashable { - case username - case password - case confirmPassword - } - - @FocusState - private var focusedfield: Field? - - // MARK: - State & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @StateObject - private var viewModel = AddServerUserViewModel() - - // MARK: - Element Variables - - @State - private var username: String = "" - @State - private var password: String = "" - @State - private var confirmPassword: String = "" - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Username is Valid - - private var isValid: Bool { - username.isNotEmpty && password == confirmPassword - } - - // MARK: - Body - - var body: some View { - List { - - Section { - TextField(L10n.username, text: $username) { - focusedfield = .password - } - .autocorrectionDisabled() - .textInputAutocapitalization(.none) - .focused($focusedfield, equals: .username) - .disabled(viewModel.state == .creatingUser) - } header: { - Text(L10n.username) - } footer: { - if username.isEmpty { - Label(L10n.usernameRequired, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } - } - - Section(L10n.password) { - UnmaskSecureField(L10n.password, text: $password) { - focusedfield = .confirmPassword - } - .autocorrectionDisabled() - .textInputAutocapitalization(.none) - .focused($focusedfield, equals: .password) - .disabled(viewModel.state == .creatingUser) - } - - Section { - UnmaskSecureField(L10n.confirmPassword, text: $confirmPassword) - .autocorrectionDisabled() - .textInputAutocapitalization(.none) - .focused($focusedfield, equals: .confirmPassword) - .disabled(viewModel.state == .creatingUser) - } header: { - Text(L10n.confirmPassword) - } footer: { - if password != confirmPassword { - Label(L10n.passwordsDoNotMatch, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } - } - } - .animation(.linear(duration: 0.2), value: isValid) - .interactiveDismissDisabled(viewModel.state == .creatingUser) - .navigationTitle(L10n.newUser) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .onFirstAppear { - focusedfield = .username - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case let .createdNewUser(newUser): - UIDevice.feedback(.success) - router.dismissCoordinator { - Notifications[.didAddServerUser].post(newUser) - } - } - } - .topBarTrailing { - if viewModel.state == .creatingUser { - ProgressView() - } - - if viewModel.state == .creatingUser { - Button(L10n.cancel) { - viewModel.send(.cancel) - } - .buttonStyle(.toolbarPill(.red)) - } else { - Button(L10n.save) { - viewModel.send(.createUser(username: username, password: password)) - } - .buttonStyle(.toolbarPill) - .disabled(!isValid) - } - } - .errorMessage($error) { - focusedfield = .username - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserDetailsView/ServerUserDetailsView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserDetailsView/ServerUserDetailsView.swift deleted file mode 100644 index 9eca6f33..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserDetailsView/ServerUserDetailsView.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct ServerUserDetailsView: View { - - // MARK: - Current Date - - @CurrentDate - private var currentDate: Date - - // MARK: - State, Observed, & Environment Objects - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @StateObject - private var viewModel: ServerUserAdminViewModel - - @StateObject - private var profileViewModel: UserProfileImageViewModel - - // MARK: - Dialog State - - @State - private var username: String - @State - private var isPresentingUsername = false - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(user: UserDto) { - self._viewModel = StateObject(wrappedValue: ServerUserAdminViewModel(user: user)) - self._profileViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: user)) - self.username = user.name ?? "" - } - - // MARK: - Body - - var body: some View { - List { - UserProfileHeroImage( - user: viewModel.user, - source: viewModel.user.profileImageSource( - client: viewModel.userSession.client, - maxWidth: 150 - ) - ) { - router.route(to: \.userPhotoPicker, profileViewModel) - } onDelete: { - profileViewModel.send(.delete) - } - - Section { - ChevronButton( - L10n.username, - subtitle: viewModel.user.name - ) { - TextField(L10n.username, text: $username) - HStack { - Button(L10n.cancel) { - username = viewModel.user.name ?? "" - isPresentingUsername = false - } - Button(L10n.save) { - viewModel.send(.updateUsername(username)) - isPresentingUsername = false - } - } - } - ChevronButton(L10n.permissions) { - router.route(to: \.userPermissions, viewModel) - } - if let userId = viewModel.user.id { - ChevronButton(L10n.password) { - router.route(to: \.resetUserPassword, userId) - } - ChevronButton(L10n.quickConnect) { - router.route(to: \.quickConnectAuthorize, viewModel.user) - } - } - } - - Section(L10n.access) { - ChevronButton(L10n.devices) { - router.route(to: \.userDeviceAccess, viewModel) - } - ChevronButton(L10n.liveTV) { - router.route(to: \.userLiveTVAccess, viewModel) - } - ChevronButton(L10n.media) { - router.route(to: \.userMediaAccess, viewModel) - } - } - - Section(L10n.parentalControls) { - ChevronButton(L10n.ratings) { - router.route(to: \.userParentalRatings, viewModel) - } - ChevronButton(L10n.accessSchedules) { - router.route(to: \.userEditAccessSchedules, viewModel) - } - ChevronButton(L10n.accessTags) { - router.route(to: \.userEditAccessTags, viewModel) - } - } - } - .navigationTitle(L10n.user) - .onAppear { - viewModel.send(.refresh) - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - error = eventError - username = viewModel.user.name ?? "" - case .updated: - break - } - } - .errorMessage($error) - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift deleted file mode 100644 index 61b8cf7e..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/AddAccessScheduleView/AddAccessScheduleView.swift +++ /dev/null @@ -1,188 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct AddAccessScheduleView: View { - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @ObservedObject - private var viewModel: ServerUserAdminViewModel - - // MARK: - Access Schedule Variables - - @State - private var tempPolicy: UserPolicy - @State - private var selectedDay: DynamicDayOfWeek = .everyday - @State - private var startTime: Date = Calendar.current.startOfDay(for: Date()) - @State - private var endTime: Date = Calendar.current.startOfDay(for: Date()).addingTimeInterval(+3600) - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(viewModel: ServerUserAdminViewModel) { - self.viewModel = viewModel - self.tempPolicy = viewModel.user.policy! - } - - private var isValidRange: Bool { - startTime < endTime - } - - private var newSchedule: AccessSchedule? { - guard isValidRange else { return nil } - - let calendar = Calendar.current - let startComponents = calendar.dateComponents([.hour, .minute], from: startTime) - let endComponents = calendar.dateComponents([.hour, .minute], from: endTime) - - guard let startHour = startComponents.hour, - let startMinute = startComponents.minute, - let endHour = endComponents.hour, - let endMinute = endComponents.minute - else { - return nil - } - - // AccessSchedule Hours are formatted as 23.5 == 11:30pm or 8.25 == 8:15am - let startDouble = Double(startHour) + Double(startMinute) / 60.0 - let endDouble = Double(endHour) + Double(endMinute) / 60.0 - - // AccessSchedule should have valid Start & End Hours - let newSchedule = AccessSchedule( - dayOfWeek: selectedDay, - endHour: endDouble, - startHour: startDouble, - userID: viewModel.user.id - ) - - return newSchedule - } - - private var isDuplicateSchedule: Bool { - guard let newSchedule, let existingSchedules = viewModel.user.policy?.accessSchedules else { - return false - } - - return existingSchedules.contains { other in - other.dayOfWeek == selectedDay && - other.startHour == newSchedule.startHour && - other.endHour == newSchedule.endHour - } - } - - // MARK: - Body - - var body: some View { - contentView - .navigationTitle(L10n.addAccessSchedule.localizedCapitalized) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.refreshing) { - ProgressView() - } - if viewModel.backgroundStates.contains(.updating) { - Button(L10n.cancel) { - viewModel.send(.cancel) - } - .buttonStyle(.toolbarPill(.red)) - } else { - Button(L10n.save) { - saveSchedule() - } - .buttonStyle(.toolbarPill) - .disabled(!isValidRange) - } - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .updated: - UIDevice.feedback(.success) - router.dismissCoordinator() - } - } - .errorMessage($error) - } - - // MARK: - Content View - - private var contentView: some View { - Form { - Section(L10n.dayOfWeek) { - Picker(L10n.dayOfWeek, selection: $selectedDay) { - ForEach(DynamicDayOfWeek.allCases, id: \.self) { day in - - if day == .everyday { - Divider() - } - - Text(day.displayTitle).tag(day) - } - } - } - - Section(L10n.startTime) { - DatePicker(L10n.startTime, selection: $startTime, displayedComponents: .hourAndMinute) - } - - Section { - DatePicker(L10n.endTime, selection: $endTime, displayedComponents: .hourAndMinute) - } header: { - Text(L10n.endTime) - } footer: { - if !isValidRange { - Label(L10n.accessScheduleInvalidTime, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } - - if isDuplicateSchedule { - Label(L10n.scheduleAlreadyExists, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } - } - } - } - - // MARK: - Save Schedule - - private func saveSchedule() { - - guard isValidRange, let newSchedule else { - error = JellyfinAPIError(L10n.accessScheduleInvalidTime) - return - } - - guard !isDuplicateSchedule else { - error = JellyfinAPIError(L10n.scheduleAlreadyExists) - return - } - - tempPolicy.accessSchedules = tempPolicy.accessSchedules - .appendedOrInit(newSchedule) - - viewModel.send(.updatePolicy(tempPolicy)) - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/Components/EditAccessScheduleRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/Components/EditAccessScheduleRow.swift deleted file mode 100644 index f3ef7541..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/Components/EditAccessScheduleRow.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension EditAccessScheduleView { - - struct EditAccessScheduleRow: View { - - // MARK: - Environment Variables - - @Environment(\.isEditing) - private var isEditing - @Environment(\.isSelected) - private var isSelected - - // MARK: - Schedule Variable - - let schedule: AccessSchedule - - // MARK: - Schedule Actions - - let onSelect: () -> Void - let onDelete: () -> Void - - // MARK: - Body - - var body: some View { - Button(action: onSelect) { - rowContent - } - .foregroundStyle(.primary, .secondary) - .swipeActions { - Button(L10n.delete, systemImage: "trash", action: onDelete) - .tint(.red) - } - } - - // MARK: - Row Content - - @ViewBuilder - private var rowContent: some View { - HStack { - VStack(alignment: .leading) { - if let dayOfWeek = schedule.dayOfWeek { - Text(dayOfWeek.rawValue) - .fontWeight(.semibold) - } - - Group { - if let startHour = schedule.startHour { - TextPairView( - leading: L10n.startTime, - trailing: doubleToTimeString(startHour) - ) - } - - if let endHour = schedule.endHour { - TextPairView( - leading: L10n.endTime, - trailing: doubleToTimeString(endHour) - ) - } - } - .font(.subheadline) - .foregroundStyle(.secondary) - } - .foregroundStyle( - isEditing ? (isSelected ? .primary : .secondary) : .primary, - .secondary - ) - - Spacer() - - ListRowCheckbox() - } - } - - // MARK: - Convert Double to Date - - private func doubleToTimeString(_ double: Double) -> String { - let startHours = Int(double) - let startMinutes = Int(double.truncatingRemainder(dividingBy: 1) * 60) - - var dateComponents = DateComponents() - dateComponents.hour = startHours - dateComponents.minute = startMinutes - - let calendar = Calendar.current - - guard let date = calendar.date(from: dateComponents) else { - return .emptyTime - } - - let formatter = DateFormatter() - formatter.timeStyle = .short - - return formatter.string(from: date) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/EditAccessScheduleView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/EditAccessScheduleView.swift deleted file mode 100644 index d5c87855..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessSchedule/EditAccessScheduleView/EditAccessScheduleView.swift +++ /dev/null @@ -1,227 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct EditAccessScheduleView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @ObservedObject - private var viewModel: ServerUserAdminViewModel - - // MARK: - Policy Variable - - @State - private var selectedSchedules: Set = [] - - // MARK: - Dialog States - - @State - private var isPresentingDeleteSelectionConfirmation = false - @State - private var isPresentingDeleteConfirmation = false - - // MARK: - Editing State - - @State - private var isEditing: Bool = false - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(viewModel: ServerUserAdminViewModel) { - self.viewModel = viewModel - } - - // MARK: - Body - - var body: some View { - contentView - .navigationTitle(L10n.accessSchedules) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isEditing) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - if isEditing { - navigationBarSelectView - } - } - ToolbarItem(placement: .topBarTrailing) { - if isEditing { - Button(L10n.cancel) { - isEditing.toggle() - selectedSchedules.removeAll() - UIDevice.impact(.light) - } - .buttonStyle(.toolbarPill) - .foregroundStyle(accentColor) - } - } - ToolbarItem(placement: .bottomBar) { - if isEditing { - Button(L10n.delete) { - isPresentingDeleteSelectionConfirmation = true - } - .buttonStyle(.toolbarPill(.red)) - .disabled(selectedSchedules.isEmpty) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.refreshing), - isHidden: isEditing || viewModel.user.policy?.accessSchedules == [] - ) { - Button(L10n.add, systemImage: "plus") { - router.route(to: \.userAddAccessSchedule, viewModel) - } - - Button(L10n.edit, systemImage: "checkmark.circle") { - isEditing = true - } - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .updated: - UIDevice.feedback(.success) - } - } - .confirmationDialog( - L10n.deleteSelectedSchedules, - isPresented: $isPresentingDeleteSelectionConfirmation, - titleVisibility: .visible - ) { - deleteSelectedSchedulesConfirmationActions - } message: { - Text(L10n.deleteSelectionSchedulesWarning) - } - .confirmationDialog( - L10n.deleteSchedule, - isPresented: $isPresentingDeleteConfirmation, - titleVisibility: .visible - ) { - deleteScheduleConfirmationActions - } message: { - Text(L10n.deleteScheduleWarning) - } - .errorMessage($error) - } - - // MARK: - Content View - - @ViewBuilder - var contentView: some View { - List { - ListTitleSection( - L10n.accessSchedules.localizedCapitalized, - description: L10n.accessSchedulesDescription - ) { - UIApplication.shared.open(.jellyfinDocsManagingUsers) - } - - if viewModel.user.policy?.accessSchedules == [] { - Button(L10n.add) { - router.route(to: \.userAddAccessSchedule, viewModel) - } - } else { - ForEach(viewModel.user.policy?.accessSchedules ?? [], id: \.self) { schedule in - EditAccessScheduleRow(schedule: schedule) { - if isEditing { - selectedSchedules.toggle(value: schedule) - } - } onDelete: { - selectedSchedules = [schedule] - isPresentingDeleteConfirmation = true - } - .environment(\.isEditing, isEditing) - .environment(\.isSelected, selectedSchedules.contains(schedule)) - } - } - } - } - - // MARK: - Navigation Bar Select/Remove All Content - - @ViewBuilder - private var navigationBarSelectView: some View { - - let isAllSelected: Bool = selectedSchedules.count == viewModel.user.policy?.accessSchedules?.count - - Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { - if isAllSelected { - selectedSchedules = [] - } else { - selectedSchedules = Set(viewModel.user.policy?.accessSchedules ?? []) - } - } - .buttonStyle(.toolbarPill) - .disabled(!isEditing) - .foregroundStyle(accentColor) - } - - // MARK: - Delete Selected Schedules Confirmation Actions - - @ViewBuilder - private var deleteSelectedSchedulesConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.confirm, role: .destructive) { - - var tempPolicy: UserPolicy = viewModel.user.policy! - - if selectedSchedules.isNotEmpty { - tempPolicy.accessSchedules = tempPolicy.accessSchedules?.filter { !selectedSchedules.contains($0) - } - viewModel.send(.updatePolicy(tempPolicy)) - isEditing = false - selectedSchedules.removeAll() - } - } - } - - // MARK: - Delete Schedule Confirmation Actions - - @ViewBuilder - private var deleteScheduleConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.delete, role: .destructive) { - - var tempPolicy: UserPolicy = viewModel.user.policy! - - if let scheduleToDelete = selectedSchedules.first, - selectedSchedules.count == 1 - { - tempPolicy.accessSchedules = tempPolicy.accessSchedules?.filter { - $0 != scheduleToDelete - } - viewModel.send(.updatePolicy(tempPolicy)) - isEditing = false - selectedSchedules.removeAll() - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/AddServerUserAccessTagsView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/AddServerUserAccessTagsView.swift deleted file mode 100644 index e0df64c7..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/AddServerUserAccessTagsView.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct AddServerUserAccessTagsView: View { - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @ObservedObject - private var viewModel: ServerUserAdminViewModel - - @StateObject - private var tagViewModel: TagEditorViewModel - - // MARK: - Access Tag Variables - - @State - private var tempPolicy: UserPolicy - @State - private var tempTag: String = "" - @State - private var access: Bool = false - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Name is Valid - - private var isValid: Bool { - tempTag.isNotEmpty && !tagIsDuplicate - } - - // MARK: - Tag is Already Blocked/Allowed - - private var tagIsDuplicate: Bool { - viewModel.user.policy!.blockedTags!.contains(tempTag) || viewModel.user.policy!.allowedTags!.contains(tempTag) - } - - // MARK: - Tag Already Exists on Jellyfin - - private var tagAlreadyExists: Bool { - tagViewModel.trie.contains(key: tempTag.localizedLowercase) - } - - // MARK: - Initializer - - init(viewModel: ServerUserAdminViewModel) { - self.viewModel = viewModel - self.tempPolicy = viewModel.user.policy! - self._tagViewModel = StateObject(wrappedValue: TagEditorViewModel(item: .init())) - } - - // MARK: - Body - - var body: some View { - contentView - .navigationTitle(L10n.addAccessTag.localizedCapitalized) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.refreshing) { - ProgressView() - } - if viewModel.backgroundStates.contains(.updating) { - Button(L10n.cancel) { - viewModel.send(.cancel) - } - .buttonStyle(.toolbarPill(.red)) - } else { - Button(L10n.save) { - if access { - tempPolicy.allowedTags = tempPolicy.allowedTags - .appendedOrInit(tempTag) - } else { - tempPolicy.blockedTags = tempPolicy.blockedTags - .appendedOrInit(tempTag) - } - - viewModel.send(.updatePolicy(tempPolicy)) - } - .buttonStyle(.toolbarPill) - .disabled(!isValid) - } - } - .onFirstAppear { - tagViewModel.send(.load) - } - .onChange(of: tempTag) { _ in - if !tagViewModel.backgroundStates.contains(.loading) { - tagViewModel.send(.search(tempTag)) - } - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .updated: - UIDevice.feedback(.success) - router.dismissCoordinator() - } - } - .onReceive(tagViewModel.events) { event in - switch event { - case .updated: - break - case .loaded: - tagViewModel.send(.search(tempTag)) - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - } - } - .errorMessage($error) - } - - // MARK: - Content View - - private var contentView: some View { - Form { - TagInput( - access: $access, - tag: $tempTag, - tagIsDuplicate: tagIsDuplicate, - tagAlreadyExists: tagAlreadyExists - ) - - SearchResultsSection( - tag: $tempTag, - tags: tagViewModel.matches, - isSearching: tagViewModel.backgroundStates.contains(.searching) - ) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/AccessTagSearchResultsSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/AccessTagSearchResultsSection.swift deleted file mode 100644 index 80c5a0bc..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/AccessTagSearchResultsSection.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddServerUserAccessTagsView { - - struct SearchResultsSection: View { - - // MARK: - Element Variables - - @Binding - var tag: String - - // MARK: - Element Search Variables - - let tags: [String] - let isSearching: Bool - - // MARK: - Body - - var body: some View { - if tag.isNotEmpty { - Section { - if tags.isNotEmpty { - resultsView - } else if !isSearching { - noResultsView - } - } header: { - HStack { - Text(L10n.existingItems) - - if isSearching { - ProgressView() - } else { - Text("-") - - Text(tags.count, format: .number) - } - } - } - .animation(.linear(duration: 0.2), value: tags) - } - } - - // MARK: - No Results View - - private var noResultsView: some View { - Text(L10n.none) - .foregroundStyle(.secondary) - } - - // MARK: - Results View - - private var resultsView: some View { - ForEach(tags, id: \.self) { result in - Button(result) { - tag = result - } - .foregroundStyle(.primary) - .disabled(tag == result) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/TagInput.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/TagInput.swift deleted file mode 100644 index c586f2f8..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/AddServerUserAccessTagsView/Components/TagInput.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddServerUserAccessTagsView { - - struct TagInput: View { - - // MARK: - Element Variables - - @FocusState - private var isTagFocused: Bool - - @Binding - var access: Bool - @Binding - var tag: String - - let tagIsDuplicate: Bool - let tagAlreadyExists: Bool - - // MARK: - Body - - var body: some View { - Section { - Picker(L10n.access, selection: $access) { - Text(L10n.allowed).tag(true) - Text(L10n.blocked).tag(false) - } - } header: { - Text(L10n.access) - } footer: { - LearnMoreButton(L10n.accessTags) { - TextPair( - title: L10n.allowed, - subtitle: L10n.accessTagAllowDescription - ) - TextPair( - title: L10n.blocked, - subtitle: L10n.accessTagBlockDescription - ) - } - } - - Section { - TextField(L10n.name, text: $tag) - .autocorrectionDisabled() - .focused($isTagFocused) - } footer: { - if tag.isEmpty { - Label( - L10n.required, - systemImage: "exclamationmark.circle.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } else if tagIsDuplicate { - Label( - L10n.accessTagAlreadyExists, - systemImage: "exclamationmark.circle.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } else { - if tagAlreadyExists { - Label( - L10n.existsOnServer, - systemImage: "checkmark.circle.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .green)) - } else { - Label( - L10n.willBeCreatedOnServer, - systemImage: "checkmark.seal.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .blue)) - } - } - } - .onFirstAppear { - isTagFocused = true - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/Components/EditAccessTagRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/Components/EditAccessTagRow.swift deleted file mode 100644 index 9f1820a3..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/Components/EditAccessTagRow.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension EditServerUserAccessTagsView { - - struct EditAccessTagRow: View { - - // MARK: - Metadata Variables - - let tag: String - - // MARK: - Row Actions - - let onSelect: () -> Void - let onDelete: () -> Void - - // MARK: - Body - - var body: some View { - Button(action: onSelect) { - HStack { - Text(tag) - .frame(maxWidth: .infinity, alignment: .leading) - - ListRowCheckbox() - } - } - .foregroundStyle(.primary) - .swipeActions { - Button( - L10n.delete, - systemImage: "trash", - action: onDelete - ) - .tint(.red) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/EditServerUserAccessTagsView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/EditServerUserAccessTagsView.swift deleted file mode 100644 index eb86403f..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessTags/EditServerUserAccessTagsView/EditServerUserAccessTagsView.swift +++ /dev/null @@ -1,247 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct EditServerUserAccessTagsView: View { - - private struct TagWithAccess: Hashable { - let tag: String - let access: Bool - } - - // MARK: - Observed, State, & Environment Objects - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @StateObject - private var viewModel: ServerUserAdminViewModel - - // MARK: - Dialog States - - @State - private var isPresentingDeleteConfirmation = false - - // MARK: - Editing States - - @State - private var selectedTags: Set = [] - @State - private var isEditing: Bool = false - - // MARK: - Error State - - @State - private var error: Error? - - private var hasTags: Bool { - viewModel.user.policy?.blockedTags?.isEmpty == true && - viewModel.user.policy?.allowedTags?.isEmpty == true - } - - private var allowedTags: [TagWithAccess] { - viewModel.user.policy?.allowedTags? - .sorted() - .map { TagWithAccess(tag: $0, access: true) } ?? [] - } - - private var blockedTags: [TagWithAccess] { - viewModel.user.policy?.blockedTags? - .sorted() - .map { TagWithAccess(tag: $0, access: false) } ?? [] - } - - // MARK: - Initializera - - init(viewModel: ServerUserAdminViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) - } - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .initial, .content: - contentView - case let .error(error): - errorView(with: error) - } - } - .navigationTitle(L10n.accessTags) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isEditing) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - if isEditing { - navigationBarSelectView - } - } - ToolbarItem(placement: .topBarTrailing) { - if isEditing { - Button(L10n.cancel) { - isEditing = false - UIDevice.impact(.light) - selectedTags.removeAll() - } - .buttonStyle(.toolbarPill) - } - } - ToolbarItem(placement: .bottomBar) { - if isEditing { - Button(L10n.delete) { - isPresentingDeleteConfirmation = true - } - .buttonStyle(.toolbarPill(.red)) - .disabled(selectedTags.isEmpty) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.refreshing), - isHidden: isEditing || hasTags - ) { - Button(L10n.add, systemImage: "plus") { - router.route(to: \.userAddAccessTag, viewModel) - } - - Button(L10n.edit, systemImage: "checkmark.circle") { - isEditing = true - } - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - error = eventError - default: - break - } - } - .confirmationDialog( - L10n.delete, - isPresented: $isPresentingDeleteConfirmation, - titleVisibility: .visible - ) { - deleteSelectedConfirmationActions - } message: { - Text(L10n.deleteSelectedConfirmation) - } - .errorMessage($error) - } - - // MARK: - ErrorView - - @ViewBuilder - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - } - - @ViewBuilder - private func makeRow(tag: TagWithAccess) -> some View { - EditAccessTagRow(tag: tag.tag) { - if isEditing { - selectedTags.toggle(value: tag) - } - } onDelete: { - selectedTags = [tag] - isPresentingDeleteConfirmation = true - } - .environment(\.isEditing, isEditing) - .environment(\.isSelected, selectedTags.contains(tag)) - } - - // MARK: - Content View - - @ViewBuilder - private var contentView: some View { - List { - ListTitleSection( - L10n.accessTags, - description: L10n.accessTagsDescription - ) { - UIApplication.shared.open(.jellyfinDocsManagingUsers) - } - - if blockedTags.isEmpty, allowedTags.isEmpty { - Button(L10n.add) { - router.route(to: \.userAddAccessTag, viewModel) - } - } else { - if allowedTags.isNotEmpty { - Section { - DisclosureGroup(L10n.allowed) { - ForEach( - allowedTags, - id: \.self, - content: makeRow - ) - } - } - } - if blockedTags.isNotEmpty { - Section { - DisclosureGroup(L10n.blocked) { - ForEach( - blockedTags, - id: \.self, - content: makeRow - ) - } - } - } - } - } - } - - // MARK: - Select/Remove All Button - - @ViewBuilder - private var navigationBarSelectView: some View { - let isAllSelected = selectedTags.count == blockedTags.count + allowedTags.count - - Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { - selectedTags = isAllSelected ? [] : Set(blockedTags + allowedTags) - } - .buttonStyle(.toolbarPill) - .disabled(!isEditing) - } - - // MARK: - Delete Selected Confirmation Actions - - @ViewBuilder - private var deleteSelectedConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.delete, role: .destructive) { - guard let policy = viewModel.user.policy else { - preconditionFailure("User policy cannot be empty.") - } - - var tempPolicy = policy - - for tag in selectedTags { - if tag.access { - tempPolicy.allowedTags?.removeAll(equalTo: tag.tag) - } else { - tempPolicy.blockedTags?.removeAll(equalTo: tag.tag) - } - } - - viewModel.send(.updatePolicy(tempPolicy)) - selectedTags.removeAll() - isEditing = false - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessView/ServerUserAccessView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessView/ServerUserAccessView.swift deleted file mode 100644 index aa8f44fb..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserAccessView/ServerUserAccessView.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct ServerUserMediaAccessView: View { - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @ObservedObject - private var viewModel: ServerUserAdminViewModel - - // MARK: - Policy Variable - - @State - private var tempPolicy: UserPolicy - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(viewModel: ServerUserAdminViewModel) { - self.viewModel = viewModel - - guard let policy = viewModel.user.policy else { - preconditionFailure("User policy cannot be empty.") - } - - self.tempPolicy = policy - } - - // MARK: - Body - - var body: some View { - contentView - .navigationTitle(L10n.mediaAccess) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.updating) { - ProgressView() - } - Button(L10n.save) { - if tempPolicy != viewModel.user.policy { - viewModel.send(.updatePolicy(tempPolicy)) - } - } - .buttonStyle(.toolbarPill) - .disabled(viewModel.user.policy == tempPolicy) - } - .onFirstAppear { - viewModel.send(.loadLibraries()) - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .updated: - UIDevice.feedback(.success) - router.dismissCoordinator() - } - } - .errorMessage($error) - } - - // MARK: - Content View - - @ViewBuilder - var contentView: some View { - List { - accessView - deletionView - } - } - - // MARK: - Media Access View - - @ViewBuilder - var accessView: some View { - Section(L10n.access) { - Toggle( - L10n.enableAllLibraries, - isOn: $tempPolicy.enableAllFolders.coalesce(false) - ) - } - - if tempPolicy.enableAllFolders == false { - Section { - ForEach(viewModel.libraries, id: \.id) { library in - Toggle( - library.displayTitle, - isOn: $tempPolicy.enabledFolders - .coalesce([]) - .contains(library.id!) - ) - } - } - } - } - - // MARK: - Media Deletion View - - @ViewBuilder - var deletionView: some View { - Section(L10n.deletion) { - Toggle( - L10n.enableAllLibraries, - isOn: $tempPolicy.enableContentDeletion.coalesce(false) - ) - } - - if tempPolicy.enableContentDeletion == false { - Section { - ForEach( - viewModel.libraries.filter { $0.collectionType != .boxsets }, - id: \.id - ) { library in - Toggle( - library.displayTitle, - isOn: $tempPolicy.enableContentDeletionFromFolders - .coalesce([]) - .contains(library.id!) - ) - } - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift deleted file mode 100644 index 3d53f30d..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserDeviceAccessView/ServerUserDeviceAccessView.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct ServerUserDeviceAccessView: View { - - // MARK: - Current Date - - @CurrentDate - private var currentDate: Date - - // MARK: - State & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @StateObject - private var viewModel: ServerUserAdminViewModel - @StateObject - private var devicesViewModel = DevicesViewModel() - - // MARK: - State Variables - - @State - private var tempPolicy: UserPolicy - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(viewModel: ServerUserAdminViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) - - guard let policy = viewModel.user.policy else { - preconditionFailure("User policy cannot be empty.") - } - - self.tempPolicy = policy - } - - // MARK: - Body - - var body: some View { - contentView - .navigationTitle(L10n.deviceAccess) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.updating) { - ProgressView() - } - Button(L10n.save) { - if tempPolicy != viewModel.user.policy { - viewModel.send(.updatePolicy(tempPolicy)) - } - } - .buttonStyle(.toolbarPill) - .disabled(viewModel.user.policy == tempPolicy) - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .updated: - UIDevice.feedback(.success) - router.dismissCoordinator() - } - } - .onFirstAppear { - devicesViewModel.send(.refresh) - } - .errorMessage($error) - } - - // MARK: - Content View - - @ViewBuilder - var contentView: some View { - List { - InsetGroupedListHeader { - Toggle( - L10n.enableAllDevices, - isOn: $tempPolicy.enableAllDevices.coalesce(false) - ) - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.vertical, 24) - - if tempPolicy.enableAllDevices == false { - Section { - ForEach(devicesViewModel.devices, id: \.self) { device in - DevicesView.DeviceRow(device: device) { - if let index = tempPolicy.enabledDevices?.firstIndex(of: device.id!) { - tempPolicy.enabledDevices?.remove(at: index) - } else { - if tempPolicy.enabledDevices == nil { - tempPolicy.enabledDevices = [] - } - tempPolicy.enabledDevices?.append(device.id!) - } - } - .environment(\.isEditing, true) - .environment(\.isSelected, tempPolicy.enabledDevices?.contains(device.id ?? "") == true) - } - } - } - } - .listStyle(.plain) - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift deleted file mode 100644 index 30aee9cb..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserLiveTVAccessView/ServerUserLiveTVAccessView.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct ServerUserLiveTVAccessView: View { - - // MARK: - Current Date - - @CurrentDate - private var currentDate: Date - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @ObservedObject - private var viewModel: ServerUserAdminViewModel - - // MARK: - Policy Variable - - @State - private var tempPolicy: UserPolicy - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(viewModel: ServerUserAdminViewModel) { - self.viewModel = viewModel - - guard let policy = viewModel.user.policy else { - preconditionFailure("User policy cannot be empty.") - } - - self.tempPolicy = policy - } - - // MARK: - Body - - var body: some View { - contentView - .navigationTitle(L10n.tvAccess) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.updating) { - ProgressView() - } - Button(L10n.save) { - if tempPolicy != viewModel.user.policy { - viewModel.send(.updatePolicy(tempPolicy)) - } - } - .buttonStyle(.toolbarPill) - .disabled(viewModel.user.policy == tempPolicy) - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .updated: - UIDevice.feedback(.success) - router.dismissCoordinator() - } - } - .errorMessage($error) - } - - // MARK: - Content View - - @ViewBuilder - var contentView: some View { - List { - Section(L10n.access) { - Toggle( - L10n.liveTVAccess, - isOn: $tempPolicy.enableLiveTvAccess.coalesce(false) - ) - Toggle( - L10n.liveTVRecordingManagement, - isOn: $tempPolicy.enableLiveTvManagement.coalesce(false) - ) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift deleted file mode 100644 index b8e0e37f..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift +++ /dev/null @@ -1,198 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct ServerUserParentalRatingView: View { - - // MARK: - Observed, State, & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @StateObject - private var viewModel: ServerUserAdminViewModel - @ObservedObject - private var parentalRatingsViewModel = ParentalRatingsViewModel() - - // MARK: - Policy Variable - - @State - private var tempPolicy: UserPolicy - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(viewModel: ServerUserAdminViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) - - guard let policy = viewModel.user.policy else { - preconditionFailure("User policy cannot be empty.") - } - - self.tempPolicy = policy - } - - // MARK: - Body - - var body: some View { - contentView - .navigationTitle(L10n.parentalRating) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.updating) { - ProgressView() - } - Button(L10n.save) { - if tempPolicy != viewModel.user.policy { - viewModel.send(.updatePolicy(tempPolicy)) - } - } - .buttonStyle(.toolbarPill) - .disabled(viewModel.user.policy == tempPolicy) - } - .onFirstAppear { - parentalRatingsViewModel.send(.refresh) - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .updated: - UIDevice.feedback(.success) - router.dismissCoordinator() - } - } - .errorMessage($error) - } - - // MARK: - Content View - - @ViewBuilder - var contentView: some View { - List { - maxParentalRatingsView - blockUnratedItemsView - } - } - - // MARK: - Maximum Parental Ratings View - - @ViewBuilder - var maxParentalRatingsView: some View { - Section { - Picker(L10n.parentalRating, selection: $tempPolicy.maxParentalRating) { - ForEach(parentalRatingGroups, id: \.value) { rating in - Text(rating.name ?? L10n.unknown) - .tag(rating.value) - } - } - } header: { - Text(L10n.maxParentalRating) - } footer: { - VStack(alignment: .leading) { - Text(L10n.maxParentalRatingDescription) - - LearnMoreButton(L10n.parentalRating) { - parentalRatingLearnMore - } - } - } - } - - // MARK: - Block Unrated Items View - - @ViewBuilder - var blockUnratedItemsView: some View { - Section { - ForEach(UnratedItem.allCases.sorted(using: \.displayTitle), id: \.self) { item in - Toggle( - item.displayTitle, - isOn: $tempPolicy.blockUnratedItems - .coalesce([]) - .contains(item) - ) - } - } header: { - Text(L10n.blockUnratedItems) - } footer: { - Text(L10n.blockUnratedItemsDescription) - } - } - - // MARK: - Parental Rating Groups - - private var parentalRatingGroups: [ParentalRating] { - let groups = Dictionary( - grouping: parentalRatingsViewModel.parentalRatings - ) { - $0.value ?? 0 - } - - var groupedRatings = groups.map { key, group in - if key < 100 { - if key == 0 { - return ParentalRating(name: L10n.allAudiences, value: key) - } else { - return ParentalRating(name: L10n.agesGroup(key), value: key) - } - } else { - // Concatenate all 100+ ratings at the same value with '/' but as of 10.10 there should be none. - let name = group - .compactMap(\.name) - .sorted() - .joined(separator: " / ") - - return ParentalRating(name: name, value: key) - } - } - .sorted(using: \.value) - - let unrated = ParentalRating(name: L10n.none, value: nil) - groupedRatings.insert(unrated, at: 0) - - return groupedRatings - } - - // MARK: - Parental Rating Learn More - - private var parentalRatingLearnMore: [TextPair] { - let groups = Dictionary( - grouping: parentalRatingsViewModel.parentalRatings - ) { - $0.value ?? 0 - } - .sorted(using: \.key) - - let groupedRatings = groups.compactMap { key, group in - let matchingRating = parentalRatingGroups.first { $0.value == key } - - let name = group - .compactMap(\.name) - .sorted() - .joined(separator: "\n") - - return TextPair( - title: matchingRating?.name ?? L10n.none, - subtitle: name - ) - } - - return groupedRatings - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ExternalAccessSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ExternalAccessSection.swift deleted file mode 100644 index 0634783b..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ExternalAccessSection.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ServerUserPermissionsView { - - struct ExternalAccessSection: View { - - @Binding - var policy: UserPolicy - - // MARK: - Body - - var body: some View { - Section(L10n.remoteConnections) { - - Toggle( - L10n.remoteConnections, - isOn: $policy.enableRemoteAccess.coalesce(false) - ) - - CaseIterablePicker( - L10n.maximumRemoteBitrate, - selection: $policy.remoteClientBitrateLimit.map( - getter: { MaxBitratePolicy(rawValue: $0) ?? .custom }, - setter: { $0.rawValue } - ) - ) - - if policy.remoteClientBitrateLimit != MaxBitratePolicy.unlimited.rawValue { - ChevronButton( - L10n.customBitrate, - subtitle: Text(policy.remoteClientBitrateLimit ?? 0, format: .bitRate), - description: L10n.enterCustomBitrate - ) { - MaxBitrateInput() - } - } - } - } - - // MARK: - Create Bitrate Input - - @ViewBuilder - private func MaxBitrateInput() -> some View { - let bitrateBinding = $policy.remoteClientBitrateLimit - .coalesce(0) - .map( - // Convert to Mbps - getter: { Double($0) / 1_000_000 }, - setter: { Int($0 * 1_000_000) } - ) - .min(0.001) // Minimum bitrate of 1 Kbps - - TextField(L10n.maximumBitrate, value: bitrateBinding, format: .number) - .keyboardType(.numbersAndPunctuation) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ManagementSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ManagementSection.swift deleted file mode 100644 index f4b28cb2..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/ManagementSection.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ServerUserPermissionsView { - - struct ManagementSection: View { - - @Binding - var policy: UserPolicy - - var body: some View { - Section(L10n.management) { - - Toggle( - L10n.administrator, - isOn: $policy.isAdministrator.coalesce(false) - ) - - Toggle( - L10n.collections, - isOn: $policy.enableCollectionManagement - ) - - Toggle( - L10n.subtitles, - isOn: $policy.enableSubtitleManagement - ) - - Toggle( - L10n.lyrics, - isOn: $policy.enableLyricManagement - ) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/MediaPlaybackSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/MediaPlaybackSection.swift deleted file mode 100644 index c3b3b23e..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/MediaPlaybackSection.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ServerUserPermissionsView { - - struct MediaPlaybackSection: View { - - @Binding - var policy: UserPolicy - - var body: some View { - Section(L10n.mediaPlayback) { - - Toggle( - L10n.mediaPlayback, - isOn: $policy.enableMediaPlayback.coalesce(false) - ) - - Toggle( - L10n.audioTranscoding, - isOn: $policy.enableAudioPlaybackTranscoding.coalesce(false) - ) - - Toggle( - L10n.videoTranscoding, - isOn: $policy.enableVideoPlaybackTranscoding.coalesce(false) - ) - - Toggle( - L10n.videoRemuxing, - isOn: $policy.enablePlaybackRemuxing.coalesce(false) - ) - - Toggle( - L10n.forceRemoteTranscoding, - isOn: $policy.isForceRemoteSourceTranscoding.coalesce(false) - ) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/PermissionSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/PermissionSection.swift deleted file mode 100644 index a0383afc..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/PermissionSection.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ServerUserPermissionsView { - - struct PermissionSection: View { - - @Binding - var policy: UserPolicy - - var body: some View { - Section(L10n.permissions) { - - Toggle( - L10n.mediaDownloads, - isOn: $policy.enableContentDownloading.coalesce(false) - ) - - Toggle( - L10n.hideUserFromLoginScreen, - isOn: $policy.isHidden.coalesce(false) - ) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/RemoteControlSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/RemoteControlSection.swift deleted file mode 100644 index e505b661..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/RemoteControlSection.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ServerUserPermissionsView { - - struct RemoteControlSection: View { - - @Binding - var policy: UserPolicy - - var body: some View { - Section(L10n.remoteControl) { - - Toggle( - L10n.controlOtherUsers, - isOn: $policy.enableRemoteControlOfOtherUsers.coalesce(false) - ) - - Toggle( - L10n.controlSharedDevices, - isOn: $policy.enableSharedDeviceControl.coalesce(false) - ) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SessionsSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SessionsSection.swift deleted file mode 100644 index 51542b4e..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SessionsSection.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ServerUserPermissionsView { - - struct SessionsSection: View { - - @Binding - var policy: UserPolicy - - // MARK: - Body - - var body: some View { - FailedLoginsView - MaxSessionsView - } - - // MARK: - Failed Login Selection View - - @ViewBuilder - private var FailedLoginsView: some View { - Section { - CaseIterablePicker( - L10n.maximumFailedLoginPolicy, - selection: $policy.loginAttemptsBeforeLockout - .coalesce(0) - .map( - getter: { LoginFailurePolicy(rawValue: $0) ?? .custom }, - setter: { $0.rawValue } - ) - ) - - if let loginAttempts = policy.loginAttemptsBeforeLockout, loginAttempts > 0 { - MaxFailedLoginsButton() - } - - } header: { - Text(L10n.sessions) - } footer: { - VStack(alignment: .leading) { - Text(L10n.maximumFailedLoginPolicyDescription) - - LearnMoreButton(L10n.maximumFailedLoginPolicy) { - TextPair( - title: L10n.lockedUsers, - subtitle: L10n.maximumFailedLoginPolicyReenable - ) - TextPair( - title: L10n.unlimited, - subtitle: L10n.unlimitedFailedLoginDescription - ) - TextPair( - title: L10n.default, - subtitle: L10n.defaultFailedLoginDescription - ) - TextPair( - title: L10n.custom, - subtitle: L10n.customFailedLoginDescription - ) - } - } - } - } - - // MARK: - Failed Login Selection Button - - @ViewBuilder - private func MaxFailedLoginsButton() -> some View { - ChevronButton( - L10n.customFailedLogins, - subtitle: Text(policy.loginAttemptsBeforeLockout ?? 1, format: .number), - description: L10n.enterCustomFailedLogins - ) { - TextField( - L10n.failedLogins, - value: $policy.loginAttemptsBeforeLockout - .coalesce(1) - .clamp(min: 1, max: 1000), - format: .number - ) - .keyboardType(.numberPad) - } - } - - // MARK: - Failed Login Validation - - @ViewBuilder - private var MaxSessionsView: some View { - Section { - CaseIterablePicker( - L10n.maximumSessionsPolicy, - selection: $policy.maxActiveSessions.map( - getter: { ActiveSessionsPolicy(rawValue: $0) ?? .custom }, - setter: { $0.rawValue } - ) - ) - - if policy.maxActiveSessions != ActiveSessionsPolicy.unlimited.rawValue { - MaxSessionsButton() - } - - } footer: { - VStack(alignment: .leading) { - Text(L10n.maximumConnectionsDescription) - - LearnMoreButton(L10n.maximumSessionsPolicy) { - TextPair( - title: L10n.unlimited, - subtitle: L10n.unlimitedConnectionsDescription - ) - TextPair( - title: L10n.custom, - subtitle: L10n.customConnectionsDescription - ) - } - } - } - } - - @ViewBuilder - private func MaxSessionsButton() -> some View { - ChevronButton( - L10n.customSessions, - subtitle: Text(policy.maxActiveSessions ?? 1, format: .number), - description: L10n.enterCustomMaxSessions - ) { - TextField( - L10n.maximumSessions, - value: $policy.maxActiveSessions - .coalesce(1) - .clamp(min: 1, max: 1000), - format: .number - ) - .keyboardType(.numberPad) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/StatusSection.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/StatusSection.swift deleted file mode 100644 index e09354ed..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/StatusSection.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ServerUserPermissionsView { - - struct StatusSection: View { - - @Binding - var policy: UserPolicy - - var body: some View { - Section(L10n.status) { - - Toggle(L10n.active, isOn: Binding( - get: { !(policy.isDisabled ?? false) }, - set: { policy.isDisabled = !$0 } - )) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SyncPlaySection.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SyncPlaySection.swift deleted file mode 100644 index e5b5283e..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/Components/Sections/SyncPlaySection.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ServerUserPermissionsView { - - struct SyncPlaySection: View { - - @Binding - var policy: UserPolicy - - var body: some View { - Section(L10n.syncPlay) { - - CaseIterablePicker( - L10n.permissions, - selection: $policy.syncPlayAccess.coalesce(.none) - ) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/ServerUserPermissionsView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/ServerUserPermissionsView.swift deleted file mode 100644 index 4531e362..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserPermissionsView/ServerUserPermissionsView.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct ServerUserPermissionsView: View { - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @ObservedObject - var viewModel: ServerUserAdminViewModel - - // MARK: - Policy Variable - - @State - private var tempPolicy: UserPolicy - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(viewModel: ServerUserAdminViewModel) { - self._viewModel = ObservedObject(wrappedValue: viewModel) - - guard let policy = viewModel.user.policy else { - preconditionFailure("User policy cannot be empty.") - } - - self.tempPolicy = policy - } - - // MARK: - Body - - var body: some View { - contentView - .navigationTitle(L10n.permissions) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.updating) { - ProgressView() - } - Button(L10n.save) { - if tempPolicy != viewModel.user.policy { - viewModel.send(.updatePolicy(tempPolicy)) - } - } - .buttonStyle(.toolbarPill) - .disabled(viewModel.user.policy == tempPolicy) - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .updated: - UIDevice.feedback(.success) - router.dismissCoordinator() - } - } - .errorMessage($error) - } - - // MARK: - Content View - - @ViewBuilder - var contentView: some View { - switch viewModel.state { - case let .error(error): - ErrorView(error: error) - case .initial: - ErrorView(error: JellyfinAPIError(L10n.loadingUserFailed)) - default: - permissionsListView - } - } - - // MARK: - Permissions List View - - @ViewBuilder - var permissionsListView: some View { - List { - StatusSection(policy: $tempPolicy) - - ManagementSection(policy: $tempPolicy) - - MediaPlaybackSection(policy: $tempPolicy) - - ExternalAccessSection(policy: $tempPolicy) - - SyncPlaySection(policy: $tempPolicy) - - RemoteControlSection(policy: $tempPolicy) - - PermissionSection(policy: $tempPolicy) - - SessionsSection(policy: $tempPolicy) - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift deleted file mode 100644 index f94bc0bc..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift +++ /dev/null @@ -1,153 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import JellyfinAPI -import SwiftUI - -extension ServerUsersView { - - struct ServerUsersRow: View { - - @Injected(\.currentUserSession) - private var userSession - - @Default(.accentColor) - private var accentColor - - // MARK: - Environment Variables - - @Environment(\.colorScheme) - private var colorScheme - @Environment(\.isEditing) - private var isEditing - @Environment(\.isSelected) - private var isSelected - - @CurrentDate - private var currentDate: Date - - private let user: UserDto - - // MARK: - Actions - - private let onSelect: () -> Void - private let onDelete: () -> Void - - // MARK: - User Status Mapping - - private var userActive: Bool { - if let isDisabled = user.policy?.isDisabled { - return !isDisabled - } else { - return false - } - } - - // MARK: - Initializer - - init( - user: UserDto, - onSelect: @escaping () -> Void, - onDelete: @escaping () -> Void - ) { - self.user = user - self.onSelect = onSelect - self.onDelete = onDelete - } - - // MARK: - Label Styling - - private var labelForegroundStyle: some ShapeStyle { - guard isEditing else { return userActive ? .primary : .secondary } - - return isSelected ? .primary : .secondary - } - - // MARK: - User Image View - - @ViewBuilder - private var userImage: some View { - ZStack { - UserProfileImage( - userID: user.id, - source: user.profileImageSource( - client: userSession!.client, - maxWidth: 60 - ) - ) - .environment(\.isEnabled, userActive) - .environment(\.isEditing, isEditing) - .environment(\.isSelected, isSelected) - } - .frame(width: 60, height: 60) - } - - // MARK: - Row Content - - @ViewBuilder - private var rowContent: some View { - HStack { - VStack(alignment: .leading) { - - Text(user.name ?? L10n.unknown) - .font(.headline) - .lineLimit(2) - .multilineTextAlignment(.leading) - - TextPairView( - L10n.role, - value: { - if let isAdministrator = user.policy?.isAdministrator, - isAdministrator - { - Text(L10n.administrator) - } else { - Text(L10n.user) - } - }() - ) - - TextPairView( - L10n.lastSeen, - value: Text(user.lastActivityDate, format: .lastSeen) - ) - .id(currentDate) - .monospacedDigit() - } - .font(.subheadline) - .foregroundStyle(labelForegroundStyle, .secondary) - - Spacer() - - ListRowCheckbox() - } - } - - // MARK: - Body - - var body: some View { - ListRow { - userImage - } content: { - rowContent - } - .onSelect(perform: onSelect) - .isSeparatorVisible(false) - .swipeActions { - Button( - L10n.delete, - systemImage: "trash", - action: onDelete - ) - .tint(.red) - } - } - } -} diff --git a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUsersView/ServerUsersView.swift b/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUsersView/ServerUsersView.swift deleted file mode 100644 index 374e72d0..00000000 --- a/jellypig iOS/Views/AdminDashboardView/ServerUsers/ServerUsersView/ServerUsersView.swift +++ /dev/null @@ -1,259 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionVGrid -import Defaults -import JellyfinAPI -import SwiftUI - -struct ServerUsersView: View { - - @Default(.accentColor) - private var accentColor - - @EnvironmentObject - private var router: AdminDashboardCoordinator.Router - - @State - private var isPresentingDeleteSelectionConfirmation = false - @State - private var isPresentingDeleteConfirmation = false - @State - private var isPresentingSelfDeleteError = false - @State - private var selectedUsers: Set = [] - @State - private var isEditing: Bool = false - - @State - private var isHiddenFilterActive: Bool = false - @State - private var isDisabledFilterActive: Bool = false - - @StateObject - private var viewModel = ServerUsersViewModel() - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - userListView - case let .error(error): - errorView(with: error) - case .initial: - DelayedProgressView() - } - } - .animation(.linear(duration: 0.2), value: viewModel.state) - .navigationTitle(L10n.users) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isEditing) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - if isEditing { - navigationBarSelectView - } - } - ToolbarItem(placement: .topBarTrailing) { - if isEditing { - Button(isEditing ? L10n.cancel : L10n.edit) { - isEditing.toggle() - - UIDevice.impact(.light) - - if !isEditing { - selectedUsers.removeAll() - } - } - .buttonStyle(.toolbarPill) - .foregroundStyle(accentColor) - } - } - ToolbarItem(placement: .bottomBar) { - if isEditing { - Button(L10n.delete) { - isPresentingDeleteSelectionConfirmation = true - } - .buttonStyle(.toolbarPill(.red)) - .disabled(selectedUsers.isEmpty) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.gettingUsers), - isHidden: isEditing - ) { - Button(L10n.addUser, systemImage: "plus") { - router.route(to: \.addServerUser) - } - - if viewModel.users.isNotEmpty { - Button(L10n.editUsers, systemImage: "checkmark.circle") { - isEditing = true - } - } - - Divider() - - Section(L10n.filters) { - Toggle(L10n.hidden, systemImage: "eye.slash", isOn: $isHiddenFilterActive) - Toggle(L10n.disabled, systemImage: "person.slash", isOn: $isDisabledFilterActive) - } - } - - .onChange(of: isDisabledFilterActive) { newValue in - viewModel.send(.getUsers( - isHidden: isHiddenFilterActive, - isDisabled: newValue - )) - } - .onChange(of: isHiddenFilterActive) { newValue in - viewModel.send(.getUsers( - isHidden: newValue, - isDisabled: isDisabledFilterActive - )) - } - .onFirstAppear { - viewModel.send(.getUsers()) - } - .confirmationDialog( - L10n.deleteSelectedUsers, - isPresented: $isPresentingDeleteSelectionConfirmation, - titleVisibility: .visible - ) { - deleteSelectedUsersConfirmationActions - } message: { - Text(L10n.deleteSelectionUsersWarning) - } - .confirmationDialog( - L10n.deleteUser, - isPresented: $isPresentingDeleteConfirmation, - titleVisibility: .visible - ) { - deleteUserConfirmationActions - } message: { - Text(L10n.deleteUserWarning) - } - .alert(L10n.deleteUserFailed, isPresented: $isPresentingSelfDeleteError) { - Button(L10n.ok, role: .cancel) {} - } message: { - Text(L10n.deleteUserSelfDeletion(viewModel.userSession.user.username)) - } - .onNotification(.didAddServerUser) { newUser in - viewModel.send(.appendUser(newUser)) - router.route(to: \.userDetails, newUser) - } - } - - // MARK: - User List View - - @ViewBuilder - private var userListView: some View { - List { - InsetGroupedListHeader( - L10n.users, - description: L10n.allUsersDescription - ) { - UIApplication.shared.open(.jellyfinDocsUsers) - } - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.vertical, 24) - - if viewModel.users.isEmpty { - Text(L10n.none) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .listRowSeparator(.hidden) - .listRowInsets(.zero) - } else { - ForEach(viewModel.users, id: \.self) { user in - if let userID = user.id { - ServerUsersRow(user: user) { - if isEditing { - selectedUsers.toggle(value: userID) - } else { - router.route(to: \.userDetails, user) - } - } onDelete: { - selectedUsers.removeAll() - selectedUsers.insert(userID) - isPresentingDeleteConfirmation = true - } - .environment(\.isEditing, isEditing) - .environment(\.isSelected, selectedUsers.contains(userID)) - .listRowInsets(.edgeInsets) - } - } - } - } - .listStyle(.plain) - } - - // MARK: - Error View - - @ViewBuilder - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.getUsers(isHidden: isHiddenFilterActive, isDisabled: isDisabledFilterActive)) - } - } - - // MARK: - Navigation Bar Select/Remove All Content - - @ViewBuilder - private var navigationBarSelectView: some View { - - let isAllSelected: Bool = selectedUsers.count == viewModel.users.count - - Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { - if isAllSelected { - selectedUsers = [] - } else { - selectedUsers = Set(viewModel.users.compactMap(\.id)) - } - } - .buttonStyle(.toolbarPill) - .disabled(!isEditing) - .foregroundStyle(accentColor) - } - - // MARK: - Delete Selected Users Confirmation Actions - - @ViewBuilder - private var deleteSelectedUsersConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.confirm, role: .destructive) { - viewModel.send(.deleteUsers(Array(selectedUsers))) - isEditing = false - selectedUsers.removeAll() - } - } - - // MARK: - Delete User Confirmation Actions - - @ViewBuilder - private var deleteUserConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.delete, role: .destructive) { - if let userToDelete = selectedUsers.first, selectedUsers.count == 1 { - if userToDelete == viewModel.userSession.user.id { - isPresentingSelfDeleteError = true - } else { - viewModel.send(.deleteUsers([userToDelete])) - selectedUsers.removeAll() - } - } - } - } -} diff --git a/jellypig iOS/Views/AppIconSelectorView.swift b/jellypig iOS/Views/AppIconSelectorView.swift deleted file mode 100644 index 1c28aa98..00000000 --- a/jellypig iOS/Views/AppIconSelectorView.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct AppIconSelectorView: View { - - @ObservedObject - var viewModel: SettingsViewModel - - var body: some View { - Form { - - Section { - ForEach(PrimaryAppIcon.allCases) { icon in - AppIconRow(viewModel: viewModel, icon: icon) - } - } - - Section(L10n.dark) { - ForEach(DarkAppIcon.allCases) { icon in - AppIconRow(viewModel: viewModel, icon: icon) - } - } - - Section(L10n.light) { - ForEach(LightAppIcon.allCases) { icon in - AppIconRow(viewModel: viewModel, icon: icon) - } - } - - Section(L10n.invertedDark) { - ForEach(InvertedDarkAppIcon.allCases) { icon in - AppIconRow(viewModel: viewModel, icon: icon) - } - } - - Section(L10n.invertedLight) { - ForEach(InvertedLightAppIcon.allCases) { icon in - AppIconRow(viewModel: viewModel, icon: icon) - } - } - } - .navigationTitle(L10n.appIcon) - } -} - -extension AppIconSelectorView { - - struct AppIconRow: View { - - @Default(.accentColor) - private var accentColor - - @ObservedObject - var viewModel: SettingsViewModel - - let icon: any AppIcon - - var body: some View { - Button { - viewModel.select(icon: icon) - } label: { - HStack { - - Image(icon.iconName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 60, height: 60) - .cornerRadius(12) - .shadow(radius: 2) - - Text(icon.displayTitle) - .foregroundColor(.primary) - - Spacer() - - if icon.iconName == viewModel.currentAppIcon.iconName { - Image(systemName: "checkmark.circle.fill") - .resizable() - .backport - .fontWeight(.bold) - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - } - } - } - } - } -} diff --git a/jellypig iOS/Views/AppLoadingView.swift b/jellypig iOS/Views/AppLoadingView.swift deleted file mode 100644 index c3338419..00000000 --- a/jellypig iOS/Views/AppLoadingView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -/// The loading view for the app when migrations are taking place -struct AppLoadingView: View { - - @State - private var didFailMigration = false - - var body: some View { - ZStack { - Color.clear - - if !didFailMigration { - DelayedProgressView() - } - - if didFailMigration { - ErrorView(error: JellyfinAPIError("An internal error occurred.")) - } - } - .topBarTrailing { - Button(L10n.advanced, systemImage: "gearshape.fill") {} - .foregroundStyle(.secondary) - .disabled(true) - .opacity(didFailMigration ? 0 : 1) - } - .onNotification(.didFailMigration) { _ in - didFailMigration = true - } - } -} diff --git a/jellypig iOS/Views/AppSettingsView/AppSettingsView.swift b/jellypig iOS/Views/AppSettingsView/AppSettingsView.swift deleted file mode 100644 index aa40a724..00000000 --- a/jellypig iOS/Views/AppSettingsView/AppSettingsView.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Stinsen -import SwiftUI - -// TODO: move sign out-stuff into super user when implemented - -struct AppSettingsView: View { - - @Default(.accentColor) - private var accentColor - - @Default(.appAppearance) - private var appearance - - @Default(.selectUserUseSplashscreen) - private var selectUserUseSplashscreen - @Default(.selectUserAllServersSplashscreen) - private var selectUserAllServersSplashscreen - - @Default(.signOutOnClose) - private var signOutOnClose - - @EnvironmentObject - private var router: AppSettingsCoordinator.Router - - @StateObject - private var viewModel = SettingsViewModel() - - var body: some View { - Form { - - ChevronButton(L10n.about) { - router.route(to: \.about, viewModel) - } - - Section(L10n.accessibility) { - - ChevronButton(L10n.appIcon) { - router.route(to: \.appIconSelector, viewModel) - } - - if !selectUserUseSplashscreen { - CaseIterablePicker( - L10n.appearance, - selection: $appearance - ) - } - } - - Section { - - Toggle(L10n.useSplashscreen, isOn: $selectUserUseSplashscreen) - - if selectUserUseSplashscreen { - Picker(L10n.servers, selection: $selectUserAllServersSplashscreen) { - - Section { - Label(L10n.random, systemImage: "dice.fill") - .tag(SelectUserServerSelection.all) - } - - ForEach(viewModel.servers) { server in - Text(server.name) - .tag(SelectUserServerSelection.server(id: server.id)) - } - } - } - } header: { - Text(L10n.splashscreen) - } footer: { - if selectUserUseSplashscreen { - Text(L10n.splashscreenFooter) - } - } - - SignOutIntervalSection() - - ChevronButton(L10n.logs) { - router.route(to: \.log) - } - } - .animation(.linear, value: selectUserUseSplashscreen) - .navigationTitle(L10n.advanced) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - } -} diff --git a/jellypig iOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift b/jellypig iOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift deleted file mode 100644 index cdc1542f..00000000 --- a/jellypig iOS/Views/AppSettingsView/Components/SignOutIntervalSection.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension AppSettingsView { - - struct SignOutIntervalSection: View { - - @Default(.backgroundSignOutInterval) - private var backgroundSignOutInterval - @Default(.signOutOnBackground) - private var signOutOnBackground - @Default(.signOutOnClose) - private var signOutOnClose - - @State - private var isEditingBackgroundSignOutInterval: Bool = false - - var body: some View { - Section { - Toggle(L10n.signoutClose, isOn: $signOutOnClose) - } footer: { - Text(L10n.signoutCloseFooter) - } - - Section { - Toggle(L10n.signoutBackground, isOn: $signOutOnBackground) - - if signOutOnBackground { - HStack { - Text(L10n.duration) - - Spacer() - - Button { - isEditingBackgroundSignOutInterval.toggle() - } label: { - HStack { - Text(backgroundSignOutInterval, format: .hourMinute) - .foregroundStyle(.secondary) - - Image(systemName: "chevron.right") - .font(.body.weight(.semibold)) - .foregroundStyle(.secondary) - .rotationEffect(isEditingBackgroundSignOutInterval ? .degrees(90) : .zero) - .animation(.linear(duration: 0.075), value: isEditingBackgroundSignOutInterval) - } - } - .foregroundStyle(.primary, .secondary) - } - - if isEditingBackgroundSignOutInterval { - HourMinutePicker(interval: $backgroundSignOutInterval) - } - } - } footer: { - Text( - L10n.signoutBackgroundFooter - ) - } - .animation(.linear(duration: 0.15), value: isEditingBackgroundSignOutInterval) - } - } -} diff --git a/jellypig iOS/Views/ChannelLibraryView/ChannelLibraryView.swift b/jellypig iOS/Views/ChannelLibraryView/ChannelLibraryView.swift deleted file mode 100644 index 194f9335..00000000 --- a/jellypig iOS/Views/ChannelLibraryView/ChannelLibraryView.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionVGrid -import Defaults -import Foundation -import JellyfinAPI -import SwiftUI - -// TODO: sorting by number/filtering -// - see if can use normal filter view model? -// - how to add custom filters for data context? -// TODO: saving item display type/detailed column count -// - wait until after user refactor - -// Note: Repurposes `LibraryDisplayType` to save from creating a new type. -// If there are other places where detailed/compact contextually differ -// from the library types, then create a new type and use it here. -// - list: detailed -// - grid: compact - -struct ChannelLibraryView: View { - - @EnvironmentObject - private var mainRouter: MainCoordinator.Router - - @State - private var channelDisplayType: LibraryDisplayType = .list - @State - private var layout: CollectionVGridLayout - - @StateObject - private var viewModel = ChannelLibraryViewModel() - - // MARK: init - - init() { - if UIDevice.isPhone { - layout = Self.padlayout(channelDisplayType: .list) - } else { - layout = Self.phonelayout(channelDisplayType: .list) - } - } - - // MARK: layout - - private static func padlayout( - channelDisplayType: LibraryDisplayType - ) -> CollectionVGridLayout { - switch channelDisplayType { - case .grid: - .minWidth(150) - case .list: - .minWidth(250) - } - } - - private static func phonelayout( - channelDisplayType: LibraryDisplayType - ) -> CollectionVGridLayout { - switch channelDisplayType { - case .grid: - .columns(3) - case .list: - .columns(1) - } - } - - // MARK: item view - - private func compactChannelView(channel: ChannelProgram) -> some View { - CompactChannelView(channel: channel.channel) - .onSelect { - guard let mediaSource = channel.channel.mediaSources?.first else { return } - mainRouter.route( - to: \.liveVideoPlayer, - LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) - ) - } - } - - private func detailedChannelView(channel: ChannelProgram) -> some View { - DetailedChannelView(channel: channel) - .onSelect { - guard let mediaSource = channel.channel.mediaSources?.first else { return } - mainRouter.route( - to: \.liveVideoPlayer, - LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) - ) - } - } - - @ViewBuilder - private var contentView: some View { - CollectionVGrid( - uniqueElements: viewModel.elements, - layout: layout - ) { channel in - switch channelDisplayType { - case .grid: - compactChannelView(channel: channel) - case .list: - detailedChannelView(channel: channel) - } - } - .onReachedBottomEdge(offset: .offset(300)) { - viewModel.send(.getNextPage) - } - } - - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - } - - var body: some View { - WrappedView { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - L10n.noResults.text - } else { - contentView - } - case let .error(error): - errorView(with: error) - case .initial, .refreshing: - DelayedProgressView() - } - } - .navigationTitle(L10n.channels) - .navigationBarTitleDisplayMode(.inline) - .onChange(of: channelDisplayType) { newValue in - if UIDevice.isPhone { - layout = Self.phonelayout(channelDisplayType: newValue) - } else { - layout = Self.padlayout(channelDisplayType: newValue) - } - } - .onFirstAppear { - if viewModel.state == .initial { - viewModel.send(.refresh) - } - } - .sinceLastDisappear { interval in - // refresh after 3 hours - if interval >= 10800 { - viewModel.send(.refresh) - } - } - .topBarTrailing { - - if viewModel.backgroundStates.contains(.gettingNextPage) { - ProgressView() - } - - Menu { - // We repurposed `LibraryDisplayType` but want different labels - Picker(L10n.channelDisplay, selection: $channelDisplayType) { - - Label(L10n.compact, systemImage: LibraryDisplayType.grid.systemImage) - .tag(LibraryDisplayType.grid) - - Label(L10n.detailed, systemImage: LibraryDisplayType.list.systemImage) - .tag(LibraryDisplayType.list) - } - } label: { - Label( - channelDisplayType.displayTitle, - systemImage: channelDisplayType.systemImage - ) - } - } - } -} diff --git a/jellypig iOS/Views/ChannelLibraryView/Components/CompactChannelView.swift b/jellypig iOS/Views/ChannelLibraryView/Components/CompactChannelView.swift deleted file mode 100644 index b4335056..00000000 --- a/jellypig iOS/Views/ChannelLibraryView/Components/CompactChannelView.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ChannelLibraryView { - - struct CompactChannelView: View { - - @Environment(\.colorScheme) - private var colorScheme - - let channel: BaseItemDto - - private var onSelect: () -> Void - - var body: some View { - Button { - onSelect() - } label: { - VStack(alignment: .leading) { - ZStack { - Color.secondarySystemFill - .opacity(colorScheme == .dark ? 0.5 : 1) - .posterShadow() - - ImageView(channel.imageSource(.primary, maxWidth: 120)) - .image { - $0.aspectRatio(contentMode: .fit) - } - .failure { - SystemImageContentView(systemName: channel.systemImage, ratio: 0.5) - .background(color: .clear) - } - .placeholder { _ in - EmptyView() - } - .padding(5) - } - .aspectRatio(1.0, contentMode: .fill) - .cornerRadius(ratio: 0.0375, of: \.width) - .posterBorder(ratio: 0.0375, of: \.width) - - Text(channel.displayTitle) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - .backport - .lineLimit(1, reservesSpace: true) - .font(.footnote.weight(.regular)) - } - } - .buttonStyle(.plain) - } - } -} - -extension ChannelLibraryView.CompactChannelView { - - init(channel: BaseItemDto) { - self.init( - channel: channel, - onSelect: {} - ) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Views/ChannelLibraryView/Components/DetailedChannelView.swift b/jellypig iOS/Views/ChannelLibraryView/Components/DetailedChannelView.swift deleted file mode 100644 index 672a326c..00000000 --- a/jellypig iOS/Views/ChannelLibraryView/Components/DetailedChannelView.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -// TODO: can look busy with 3 programs, probably just do 2? - -extension ChannelLibraryView { - - struct DetailedChannelView: View { - - @Default(.accentColor) - private var accentColor - - @Environment(\.colorScheme) - private var colorScheme - - @State - private var contentSize: CGSize = .zero - @State - private var now: Date = .now - - let channel: ChannelProgram - - private var onSelect: () -> Void - private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - - @ViewBuilder - private var channelLogo: some View { - VStack { - ZStack { - Color.secondarySystemFill - .opacity(colorScheme == .dark ? 0.5 : 1) - .posterShadow() - - ImageView(channel.channel.imageSource(.primary, maxWidth: 120)) - .image { - $0.aspectRatio(contentMode: .fit) - } - .failure { - SystemImageContentView(systemName: channel.systemImage, ratio: 0.5) - .background(color: .clear) - } - .placeholder { _ in - EmptyView() - } - .padding(5) - } - .aspectRatio(1.0, contentMode: .fill) - .posterBorder(ratio: 0.0375, of: \.width) - .cornerRadius(ratio: 0.0375, of: \.width) - - Text(channel.channel.number ?? "") - .font(.body) - .lineLimit(1) - .foregroundColor(Color.jellyfinPurple) - } - } - - @ViewBuilder - private func programLabel(for program: BaseItemDto) -> some View { - HStack(alignment: .top) { - AlternateLayoutView(alignment: .leading) { - Text("00:00 AAA") - .monospacedDigit() - } content: { - if let startDate = program.startDate { - Text(startDate, style: .time) - .monospacedDigit() - } else { - Text(String.emptyTime) - } - } - - Text(program.displayTitle) - } - .lineLimit(1) - } - - @ViewBuilder - private var programListView: some View { - VStack(alignment: .leading, spacing: 0) { - if let currentProgram = channel.currentProgram { - ProgressBar(progress: currentProgram.programProgress(relativeTo: now) ?? 0) - .frame(height: 5) - .padding(.bottom, 5) - .foregroundStyle(accentColor) - - programLabel(for: currentProgram) - .font(.footnote.weight(.bold)) - } - - if let nextProgram = channel.programAfterCurrent(offset: 0) { - programLabel(for: nextProgram) - .font(.footnote) - .foregroundStyle(.secondary) - } - - if let futureProgram = channel.programAfterCurrent(offset: 1) { - programLabel(for: futureProgram) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .id(channel.currentProgram) - } - - var body: some View { - ZStack(alignment: .bottomTrailing) { - Button { - onSelect() - } label: { - HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { - - channelLogo - .frame(width: 80) - .padding(.vertical, 8) - - HStack { - VStack(alignment: .leading, spacing: 5) { - Text(channel.displayTitle) - .font(.body) - .fontWeight(.semibold) - .lineLimit(1) - .foregroundStyle(.primary) - - if channel.programs.isNotEmpty { - programListView - } - } - - Spacer() - } - .frame(maxWidth: .infinity) - .trackingSize($contentSize) - } - } - .buttonStyle(.plain) - - Color.secondarySystemFill - .frame(width: contentSize.width, height: 1) - } - .onReceive(timer) { newValue in - now = newValue - } - .animation(.linear(duration: 0.2), value: channel.currentProgram) - } - } -} - -extension ChannelLibraryView.DetailedChannelView { - - init(channel: ChannelProgram) { - self.init( - channel: channel, - onSelect: {} - ) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Views/ConnectToServerView.swift b/jellypig iOS/Views/ConnectToServerView.swift deleted file mode 100644 index 1c9687c9..00000000 --- a/jellypig iOS/Views/ConnectToServerView.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import SwiftUI - -struct ConnectToServerView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Focus Fields - - @FocusState - private var isURLFocused: Bool - - // MARK: - State & Environment Objects - - @EnvironmentObject - private var router: SelectUserCoordinator.Router - - @StateObject - private var viewModel = ConnectToServerViewModel() - - // MARK: - URL Variable - - @State - private var url: String = "" - - // MARK: - Duplicate Server State - - @State - private var duplicateServer: ServerState? = nil - @State - private var isPresentingDuplicateServer: Bool = false - - // MARK: - Error State - - @State - private var error: Error? = nil - - // MARK: - Connection Timer - - private let timer = Timer.publish(every: 12, on: .main, in: .common).autoconnect() - - // MARK: - Handle Connection - - private func handleConnection(_ event: ConnectToServerViewModel.Event) { - switch event { - case let .connected(server): - UIDevice.feedback(.success) - - Notifications[.didConnectToServer].post(server) - router.popLast() - case let .duplicateServer(server): - UIDevice.feedback(.warning) - - duplicateServer = server - isPresentingDuplicateServer = true - case let .error(eventError): - UIDevice.feedback(.error) - - error = eventError - isURLFocused = true - } - } - - // MARK: - Connect Section - - @ViewBuilder - private var connectSection: some View { - Section(L10n.connectToServer) { - TextField(L10n.serverURL, text: $url) - .disableAutocorrection(true) - .textInputAutocapitalization(.never) - .keyboardType(.URL) - .focused($isURLFocused) - } - - if viewModel.state == .connecting { - ListRowButton(L10n.cancel) { - viewModel.send(.cancel) - } - .foregroundStyle(.red, .red.opacity(0.2)) - } else { - ListRowButton(L10n.connect) { - isURLFocused = false - viewModel.send(.connect(url)) - } - .disabled(url.isEmpty) - .foregroundStyle( - accentColor.overlayColor, - accentColor - ) - .opacity(url.isEmpty ? 0.5 : 1) - } - } - - // MARK: - Local Server Button - - private func localServerButton(for server: ServerState) -> some View { - Button { - url = server.currentURL.absoluteString - viewModel.send(.connect(server.currentURL.absoluteString)) - } label: { - HStack { - VStack(alignment: .leading) { - Text(server.name) - .font(.headline) - .fontWeight(.semibold) - - Text(server.currentURL.absoluteString) - .font(.subheadline) - .foregroundColor(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.body.weight(.regular)) - .foregroundColor(.secondary) - } - } - .disabled(viewModel.state == .connecting) - .buttonStyle(.plain) - } - - // MARK: - Local Servers Section - - @ViewBuilder - private var localServersSection: some View { - Section(L10n.localServers) { - if viewModel.localServers.isEmpty { - L10n.noLocalServersFound.text - .font(.callout) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity) - } else { - ForEach(viewModel.localServers) { server in - localServerButton(for: server) - } - } - } - } - - // MARK: - Body - - var body: some View { - List { - connectSection - - localServersSection - } - .interactiveDismissDisabled(viewModel.state == .connecting) - .navigationTitle(L10n.connect) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton(disabled: viewModel.state == .connecting) { - router.popLast() - } - .onFirstAppear { - isURLFocused = true - viewModel.send(.searchForServers) - } - .onReceive(viewModel.events) { event in - handleConnection(event) - } - .onReceive(timer) { _ in - guard viewModel.state != .connecting else { return } - - viewModel.send(.searchForServers) - } - .topBarTrailing { - if viewModel.state == .connecting { - ProgressView() - } - } - .alert( - L10n.server.text, - isPresented: $isPresentingDuplicateServer, - presenting: duplicateServer - ) { server in - Button(L10n.dismiss, role: .destructive) - - Button(L10n.addURL) { - viewModel.send(.addNewURL(server)) - router.popLast() - } - } message: { server in - L10n.serverAlreadyExistsPrompt(server.name).text - } - .errorMessage($error) - } -} diff --git a/jellypig iOS/Views/DownloadListView.swift b/jellypig iOS/Views/DownloadListView.swift deleted file mode 100644 index 7ec2c126..00000000 --- a/jellypig iOS/Views/DownloadListView.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct DownloadListView: View { - - @ObservedObject - var viewModel: DownloadListViewModel - - var body: some View { - ScrollView(showsIndicators: false) { - ForEach(viewModel.items) { item in - DownloadTaskRow(downloadTask: item) - } - } - .navigationTitle(L10n.downloads) - .navigationBarTitleDisplayMode(.inline) - } -} - -extension DownloadListView { - - struct DownloadTaskRow: View { - - @EnvironmentObject - private var router: DownloadListCoordinator.Router - - let downloadTask: DownloadTask - - var body: some View { - Button { - router.route(to: \.downloadTask, downloadTask) - } label: { - HStack(alignment: .bottom) { - ImageView(downloadTask.getImageURL(name: "Primary")) - .failure { - Color.secondary - .opacity(0.8) - } -// .posterStyle(type: .portrait, width: 60) - .posterShadow() - - VStack(alignment: .leading) { - Text(downloadTask.item.displayTitle) - .foregroundColor(.primary) - .fontWeight(.semibold) - .lineLimit(2) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical) - - Spacer() - } - } - } - } -} diff --git a/jellypig iOS/Views/DownloadTaskView/DownloadTaskContentView.swift b/jellypig iOS/Views/DownloadTaskView/DownloadTaskContentView.swift deleted file mode 100644 index 263fe2b7..00000000 --- a/jellypig iOS/Views/DownloadTaskView/DownloadTaskContentView.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import JellyfinAPI -import SwiftUI - -extension DownloadTaskView { - - struct ContentView: View { - - @Default(.accentColor) - private var accentColor - - @Injected(\.downloadManager) - private var downloadManager - - @EnvironmentObject - private var mainCoordinator: MainCoordinator.Router - @EnvironmentObject - private var router: DownloadTaskCoordinator.Router - - @ObservedObject - var downloadTask: DownloadTask - - @State - private var isPresentingVideoPlayerTypeError: Bool = false - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - - VStack(alignment: .center) { - ImageView(downloadTask.item.landscapeImageSources(maxWidth: 600)) - .frame(maxHeight: 300) - .aspectRatio(1.77, contentMode: .fill) - .cornerRadius(10) - .padding(.horizontal) - .posterShadow() - - ShelfView(downloadTask: downloadTask) - - // TODO: Break into subview - switch downloadTask.state { - case .ready, .cancelled: - PrimaryButton(title: "Download") - .onSelect { - downloadManager.download(task: downloadTask) - } - .frame(maxWidth: 300) - .frame(height: 50) - case let .downloading(progress): - HStack { -// CircularProgressView(progress: progress) -// .buttonStyle(.plain) -// .frame(width: 30, height: 30) - - Text("\(Int(progress * 100))%") - .foregroundColor(.secondary) - - Spacer() - - Button { - downloadManager.cancel(task: downloadTask) - } label: { - Image(systemName: "stop.circle") - .foregroundColor(.red) - } - } - .padding(.horizontal) - case let .error(error): - VStack { - PrimaryButton(title: L10n.retry) - .onSelect { - downloadManager.download(task: downloadTask) - } - .frame(maxWidth: 300) - .frame(height: 50) - - Text("Error: \(error.localizedDescription)") - .padding(.horizontal) - } - case .complete: - PrimaryButton(title: L10n.play) - .onSelect { - if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { - router.dismissCoordinator { - mainCoordinator.route(to: \.videoPlayer, DownloadVideoPlayerManager(downloadTask: downloadTask)) - } - } else { - isPresentingVideoPlayerTypeError = true - } - } - .frame(maxWidth: 300) - .frame(height: 50) - } - } - -// Text("Media Info") -// .font(.title2) -// .fontWeight(.semibold) -// .padding(.horizontal) - } - .alert( - L10n.error, - isPresented: $isPresentingVideoPlayerTypeError - ) { - Button { - isPresentingVideoPlayerTypeError = false - } label: { - Text(L10n.dismiss) - } - } message: { - Text("Downloaded items are only playable through the Swiftfin video player.") - } - } - } -} - -extension DownloadTaskView.ContentView { - - struct ShelfView: View { - - @ObservedObject - var downloadTask: DownloadTask - - var body: some View { - VStack(alignment: .center, spacing: 10) { - - if let seriesName = downloadTask.item.seriesName { - Text(seriesName) - .font(.headline) - .fontWeight(.semibold) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.horizontal) - .foregroundColor(.secondary) - } - - Text(downloadTask.item.displayTitle) - .font(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.horizontal) - - DotHStack { - if downloadTask.item.type == .episode { - if let episodeLocation = downloadTask.item.episodeLocator { - Text(episodeLocation) - } - } else { - if let firstGenre = downloadTask.item.genres?.first { - Text(firstGenre) - } - } - - if let productionYear = downloadTask.item.premiereDateYear { - Text(productionYear) - } - - if let runtime = downloadTask.item.runTimeLabel { - Text(runtime) - } - } - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal) - } - } - } -} diff --git a/jellypig iOS/Views/DownloadTaskView/DownloadTaskView.swift b/jellypig iOS/Views/DownloadTaskView/DownloadTaskView.swift deleted file mode 100644 index 3af9d765..00000000 --- a/jellypig iOS/Views/DownloadTaskView/DownloadTaskView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct DownloadTaskView: View { - - @EnvironmentObject - private var router: DownloadTaskCoordinator.Router - - @ObservedObject - var downloadTask: DownloadTask - - var body: some View { - ScrollView(showsIndicators: false) { - ContentView(downloadTask: downloadTask) - } - .navigationBarCloseButton { - router.dismissCoordinator() - } - } -} diff --git a/jellypig iOS/Views/EditServerView.swift b/jellypig iOS/Views/EditServerView.swift deleted file mode 100644 index 5bb82132..00000000 --- a/jellypig iOS/Views/EditServerView.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Factory -import SwiftUI - -// Note: uses environment `isEditing` for deletion button. This was done -// to just prevent having 2 views that looked/interacted the same -// except for a single button. - -// TODO: change URL picker from menu to list with network-url mapping - -struct EditServerView: View { - - @EnvironmentObject - private var router: SelectUserCoordinator.Router - - @Environment(\.isEditing) - private var isEditing - - @State - private var currentServerURL: URL - @State - private var isPresentingConfirmDeletion: Bool = false - - @StateObject - private var viewModel: ServerConnectionViewModel - - init(server: ServerState) { - self._viewModel = StateObject(wrappedValue: ServerConnectionViewModel(server: server)) - self._currentServerURL = State(initialValue: server.currentURL) - } - - var body: some View { - List { - Section { - - TextPairView( - leading: L10n.name, - trailing: viewModel.server.name - ) - - Picker(L10n.url, selection: $currentServerURL) { - ForEach(viewModel.server.urls.sorted(using: \.absoluteString)) { url in - Text(url.absoluteString) - .tag(url) - .foregroundColor(.secondary) - } - } - } - - if isEditing { - ListRowButton(L10n.delete) { - isPresentingConfirmDeletion = true - } - .foregroundStyle(.red, .red.opacity(0.2)) - } - } - .navigationTitle(L10n.server) - .navigationBarTitleDisplayMode(.inline) - .onChange(of: currentServerURL) { newValue in - viewModel.setCurrentURL(to: newValue) - } - .alert(L10n.deleteServer, isPresented: $isPresentingConfirmDeletion) { - Button(L10n.delete, role: .destructive) { - viewModel.delete() - router.popLast() - } - } message: { - Text(L10n.confirmDeleteServerAndUsers(viewModel.server.name)) - } - } -} diff --git a/jellypig iOS/Views/FilterView.swift b/jellypig iOS/Views/FilterView.swift deleted file mode 100644 index 8885bf27..00000000 --- a/jellypig iOS/Views/FilterView.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -// TODO: multiple filter types? -// - for sort order and sort by combined -struct FilterView: View { - - @Binding - private var selection: [AnyItemFilter] - - @EnvironmentObject - private var router: FilterCoordinator.Router - - @ObservedObject - private var viewModel: FilterViewModel - - private let type: ItemFilterType - - var body: some View { - SelectorView( - selection: $selection, - sources: viewModel.allFilters[keyPath: type.collectionAnyKeyPath], - type: type.selectorType - ) - .navigationTitle(type.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - Button(L10n.reset) { - viewModel.send(.reset(type)) - } - .environment( - \.isEnabled, - viewModel.isFilterSelected(type: type) - ) - } - } -} - -extension FilterView { - - init( - viewModel: FilterViewModel, - type: ItemFilterType - ) { - - let selectionBinding: Binding<[AnyItemFilter]> = Binding { - viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] - } set: { newValue in - viewModel.send(.update(type, newValue)) - } - - self.init( - selection: selectionBinding, - viewModel: viewModel, - type: type - ) - } -} diff --git a/jellypig iOS/Views/FontPickerView.swift b/jellypig iOS/Views/FontPickerView.swift deleted file mode 100644 index 830bfd23..00000000 --- a/jellypig iOS/Views/FontPickerView.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI -import UIKit - -struct FontPickerView: View { - - @Binding - var selection: String - - var body: some View { - SelectorView( - selection: $selection, - sources: UIFont.familyNames - ) - .label { fontFamily in - Text(fontFamily) - .foregroundColor(.primary) - .font(.custom(fontFamily, size: 18)) - } - .navigationTitle(L10n.subtitleFont) - } -} diff --git a/jellypig iOS/Views/HomeView/Components/ContinueWatchingView.swift b/jellypig iOS/Views/HomeView/Components/ContinueWatchingView.swift deleted file mode 100644 index be52dcc6..00000000 --- a/jellypig iOS/Views/HomeView/Components/ContinueWatchingView.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionHStack -import JellyfinAPI -import OrderedCollections -import SwiftUI - -extension HomeView { - - struct ContinueWatchingView: View { - - @EnvironmentObject - private var router: HomeCoordinator.Router - - @ObservedObject - var viewModel: HomeViewModel - - // TODO: see how this looks across multiple screen sizes - // alongside PosterHStack + landscape - // TODO: need better handling for iPadOS + portrait orientation - private var columnCount: CGFloat { - if UIDevice.isPhone { - 1.5 - } else { - 3.5 - } - } - - var body: some View { - CollectionHStack( - uniqueElements: viewModel.resumeItems, - columns: columnCount - ) { item in - PosterButton(item: item, type: .landscape) - .content { - if item.type == .episode { - PosterButton.EpisodeContentSubtitleContent(item: item) - } else { - PosterButton.TitleSubtitleContentView(item: item) - } - } - .contextMenu { - Button { - viewModel.send(.setIsPlayed(true, item)) - } label: { - Label(L10n.played, systemImage: "checkmark.circle") - } - - Button(role: .destructive) { - viewModel.send(.setIsPlayed(false, item)) - } label: { - Label(L10n.unplayed, systemImage: "minus.circle") - } - } - .imageOverlay { - LandscapePosterProgressBar( - title: item.progressLabel ?? L10n.continue, - progress: (item.userData?.playedPercentage ?? 0) / 100 - ) - } - .onSelect { - router.route(to: \.item, item) - } - } - .scrollBehavior(.continuousLeadingEdge) - } - } -} diff --git a/jellypig iOS/Views/HomeView/Components/LatestInLibraryView.swift b/jellypig iOS/Views/HomeView/Components/LatestInLibraryView.swift deleted file mode 100644 index 0954b31e..00000000 --- a/jellypig iOS/Views/HomeView/Components/LatestInLibraryView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionHStack -import Defaults -import JellyfinAPI -import OrderedCollections -import SwiftUI - -extension HomeView { - - struct LatestInLibraryView: View { - - @Default(.Customization.latestInLibraryPosterType) - private var latestInLibraryPosterType - - @EnvironmentObject - private var router: HomeCoordinator.Router - - @ObservedObject - var viewModel: LatestInLibraryViewModel - - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), - type: latestInLibraryPosterType, - items: viewModel.elements - ) - .trailing { - SeeAllButton() - .onSelect { - router.route(to: \.library, viewModel) - } - } - .onSelect { item in - router.route(to: \.item, item) - } - } - } - } -} diff --git a/jellypig iOS/Views/HomeView/Components/NextUpView.swift b/jellypig iOS/Views/HomeView/Components/NextUpView.swift deleted file mode 100644 index fd716f26..00000000 --- a/jellypig iOS/Views/HomeView/Components/NextUpView.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionHStack -import Defaults -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct NextUpView: View { - - @Default(.Customization.nextUpPosterType) - private var nextUpPosterType - - @EnvironmentObject - private var router: HomeCoordinator.Router - - @ObservedObject - var viewModel: NextUpLibraryViewModel - - private var onSetPlayed: (BaseItemDto) -> Void - - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.nextUp, - type: nextUpPosterType, - items: viewModel.elements - ) - .content { item in - if item.type == .episode { - PosterButton.EpisodeContentSubtitleContent(item: item) - } else { - PosterButton.TitleSubtitleContentView(item: item) - } - } - .contextMenu { item in - Button { - onSetPlayed(item) - } label: { - Label(L10n.played, systemImage: "checkmark.circle") - } - } - .onSelect { item in - router.route(to: \.item, item) - } - .trailing { - SeeAllButton() - .onSelect { - router.route(to: \.library, viewModel) - } - } - } - } - } -} - -extension HomeView.NextUpView { - - init(viewModel: NextUpLibraryViewModel) { - self.init( - viewModel: viewModel, - onSetPlayed: { _ in } - ) - } - - func onSetPlayed(perform action: @escaping (BaseItemDto) -> Void) -> Self { - copy(modifying: \.onSetPlayed, with: action) - } -} diff --git a/jellypig iOS/Views/HomeView/Components/RecentlyAddedView.swift b/jellypig iOS/Views/HomeView/Components/RecentlyAddedView.swift deleted file mode 100644 index e0423efe..00000000 --- a/jellypig iOS/Views/HomeView/Components/RecentlyAddedView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct RecentlyAddedView: View { - - @Default(.Customization.recentlyAddedPosterType) - private var recentlyAddedPosterType - - @EnvironmentObject - private var router: HomeCoordinator.Router - - @ObservedObject - var viewModel: RecentlyAddedLibraryViewModel - - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.recentlyAdded, - type: recentlyAddedPosterType, - items: viewModel.elements - ) - .trailing { - SeeAllButton() - .onSelect { - // Give a new view model becaues we don't want to - // keep paginated items on the home view model - let viewModel = RecentlyAddedLibraryViewModel() - router.route(to: \.library, viewModel) - } - } - .onSelect { item in - router.route(to: \.item, item) - } - } - } - } -} diff --git a/jellypig iOS/Views/HomeView/HomeView.swift b/jellypig iOS/Views/HomeView/HomeView.swift deleted file mode 100644 index 564550fd..00000000 --- a/jellypig iOS/Views/HomeView/HomeView.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import Foundation -import SwiftUI - -// TODO: seems to redraw view when popped to sometimes? -// - similar to MediaView TODO bug? -// - indicated by snapping to the top -struct HomeView: View { - - @Default(.Customization.nextUpPosterType) - private var nextUpPosterType - @Default(.Customization.Home.showRecentlyAdded) - private var showRecentlyAdded - @Default(.Customization.recentlyAddedPosterType) - private var recentlyAddedPosterType - - @EnvironmentObject - private var mainRouter: MainCoordinator.Router - @EnvironmentObject - private var router: HomeCoordinator.Router - - @StateObject - private var viewModel = HomeViewModel() - - @ViewBuilder - private var contentView: some View { - ScrollView { - VStack(alignment: .leading, spacing: 10) { - - ContinueWatchingView(viewModel: viewModel) - - NextUpView(viewModel: viewModel.nextUpViewModel) - .onSetPlayed { item in - viewModel.send(.setIsPlayed(true, item)) - } - - if showRecentlyAdded { - RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) - } - - ForEach(viewModel.libraries) { viewModel in - LatestInLibraryView(viewModel: viewModel) - } - } - .edgePadding(.vertical) - } - .refreshable { - viewModel.send(.refresh) - } - } - - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - } - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - contentView - case let .error(error): - errorView(with: error) - case .initial, .refreshing: - DelayedProgressView() - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .onFirstAppear { - viewModel.send(.refresh) - } - .navigationTitle(L10n.home) - .topBarTrailing { - - if viewModel.backgroundStates.contains(.refresh) { - ProgressView() - } - - SettingsBarButton( - server: viewModel.userSession.server, - user: viewModel.userSession.user - ) { - mainRouter.route(to: \.settings) - } - } - .sinceLastDisappear { interval in - if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { - viewModel.send(.backgroundRefresh) - viewModel.notificationsReceived.remove(.itemMetadataDidChange) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/Components/RefreshMetadataButton.swift b/jellypig iOS/Views/ItemEditorView/Components/RefreshMetadataButton.swift deleted file mode 100644 index 07ec23e9..00000000 --- a/jellypig iOS/Views/ItemEditorView/Components/RefreshMetadataButton.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemEditorView { - - struct RefreshMetadataButton: View { - - // MARK: - Environment & State Objects - - // Bug in SwiftUI where Menu item icons will be black in dark mode - // when a HierarchicalShapeStyle is applied to the Buttons - @Environment(\.colorScheme) - private var colorScheme: ColorScheme - - @StateObject - private var viewModel: RefreshMetadataViewModel - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(item: BaseItemDto) { - _viewModel = StateObject(wrappedValue: RefreshMetadataViewModel(item: item)) - } - - // MARK: - Body - - var body: some View { - Menu { - Group { - Button(L10n.findMissing, systemImage: "magnifyingglass") { - viewModel.send( - .refreshMetadata( - metadataRefreshMode: .fullRefresh, - imageRefreshMode: .fullRefresh, - replaceMetadata: false, - replaceImages: false - ) - ) - } - - Button(L10n.replaceMetadata, systemImage: "arrow.clockwise") { - viewModel.send( - .refreshMetadata( - metadataRefreshMode: .fullRefresh, - imageRefreshMode: .none, - replaceMetadata: true, - replaceImages: false - ) - ) - } - - Button(L10n.replaceImages, systemImage: "photo") { - viewModel.send( - .refreshMetadata( - metadataRefreshMode: .none, - imageRefreshMode: .fullRefresh, - replaceMetadata: false, - replaceImages: true - ) - ) - } - - Button(L10n.replaceAll, systemImage: "staroflife") { - viewModel.send( - .refreshMetadata( - metadataRefreshMode: .fullRefresh, - imageRefreshMode: .fullRefresh, - replaceMetadata: true, - replaceImages: true - ) - ) - } - } - .foregroundStyle(colorScheme == .dark ? Color.white : Color.black) - } label: { - HStack { - Text(L10n.refreshMetadata) - .foregroundStyle(.primary) - - Spacer() - - if viewModel.state == .refreshing { - ProgressView(value: viewModel.progress) - .progressViewStyle(.gauge) - .transition(.opacity.combined(with: .scale).animation(.bouncy)) - .frame(width: 25, height: 25) - } else { - Image(systemName: "arrow.clockwise") - .foregroundStyle(.secondary) - .backport - .fontWeight(.semibold) - } - } - } - .foregroundStyle(.primary, .secondary) - .disabled(viewModel.state == .refreshing || error != nil) - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - error = eventError - } - } - .errorMessage($error) - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultRow.swift b/jellypig iOS/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultRow.swift deleted file mode 100644 index 03223c91..00000000 --- a/jellypig iOS/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultRow.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension IdentifyItemView { - - struct RemoteSearchResultRow: View { - - // MARK: - Remote Search Result Variable - - let result: RemoteSearchResult - - // MARK: - Remote Search Result Action - - let onSelect: () -> Void - - // MARK: - Result Title - - private var resultTitle: String { - result.displayTitle - .appending(" (\(result.premiereDate!.formatted(.dateTime.year())))", if: result.premiereDate != nil) - } - - // MARK: - Body - - var body: some View { - ListRow { - IdentifyItemView.resultImage(URL(string: result.imageURL)) - .frame(width: 60) - } content: { - VStack(alignment: .leading) { - Text(resultTitle) - .font(.headline) - .foregroundStyle(.primary) - - if let overview = result.overview { - Text(overview) - .lineLimit(3) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } - .onSelect(perform: onSelect) - .isSeparatorVisible(false) - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultView.swift b/jellypig iOS/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultView.swift deleted file mode 100644 index 669d7168..00000000 --- a/jellypig iOS/Views/ItemEditorView/IdentifyItemView/Components/RemoteSearchResultView.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension IdentifyItemView { - - struct RemoteSearchResultView: View { - - // MARK: - Item Info Variables - - let result: RemoteSearchResult - - // MARK: - Item Info Actions - - let onSave: () -> Void - let onClose: () -> Void - - // MARK: - Body - - @ViewBuilder - private var header: some View { - Section { - HStack(alignment: .bottom, spacing: 12) { - IdentifyItemView.resultImage(URL(string: result.imageURL)) - .frame(width: 100) - .accessibilityIgnoresInvertColors() - - Text(result.displayTitle) - .font(.title2) - .fontWeight(.semibold) - .foregroundStyle(.primary) - .lineLimit(2) - .padding(.bottom) - } - } - .listRowBackground(Color.clear) - .listRowCornerRadius(0) - .listRowInsets(.zero) - } - - @ViewBuilder - private var resultDetails: some View { - Section(L10n.details) { - - if let premiereDate = result.premiereDate { - TextPairView( - L10n.premiereDate, - value: Text(premiereDate.formatted(.dateTime.year().month().day())) - ) - } - - if let productionYear = result.productionYear { - TextPairView( - L10n.productionYear, - value: Text(productionYear, format: .number.grouping(.never)) - ) - } - - if let provider = result.searchProviderName { - TextPairView( - leading: L10n.provider, - trailing: provider - ) - } - - if let providerID = result.providerIDs?.values.first { - TextPairView( - leading: L10n.id, - trailing: providerID - ) - } - } - - if let overview = result.overview { - Section(L10n.overview) { - Text(overview) - } - } - } - - var body: some View { - NavigationView { - List { - header - - resultDetails - } - .navigationTitle(L10n.identify) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - onClose() - } - .topBarTrailing { - Button(L10n.save, action: onSave) - .buttonStyle(.toolbarPill) - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift b/jellypig iOS/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift deleted file mode 100644 index ef42169d..00000000 --- a/jellypig iOS/Views/ItemEditorView/IdentifyItemView/IdentifyItemView.swift +++ /dev/null @@ -1,197 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct IdentifyItemView: View { - - private struct SearchFields: Equatable { - var name: String? - var originalTitle: String? - var year: Int? - - var isEmpty: Bool { - name.isNilOrEmpty && - originalTitle.isNilOrEmpty && - year == nil - } - } - - @Default(.accentColor) - private var accentColor - - @FocusState - private var isTitleFocused: Bool - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: ItemEditorCoordinator.Router - - @StateObject - private var viewModel: IdentifyItemViewModel - - // MARK: - Identity Variables - - @State - private var selectedResult: RemoteSearchResult? - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Lookup States - - @State - private var search = SearchFields() - - // MARK: - Initializer - - init(item: BaseItemDto) { - self._viewModel = StateObject(wrappedValue: IdentifyItemViewModel(item: item)) - } - - // MARK: - Body - - var body: some View { - Group { - switch viewModel.state { - case .content, .searching: - contentView - case .updating: - ProgressView() - } - } - .navigationTitle(L10n.identify) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(viewModel.state == .updating) - .sheet(item: $selectedResult) { - selectedResult = nil - } content: { result in - RemoteSearchResultView( - result: result, - onSave: { - selectedResult = nil - viewModel.send(.update(result)) - }, - onClose: { - selectedResult = nil - } - ) - } - .onReceive(viewModel.events) { events in - switch events { - case let .error(eventError): - error = eventError - case .cancelled: - selectedResult = nil - case .updated: - router.pop() - } - } - .errorMessage($error) - .onFirstAppear { - isTitleFocused = true - } - } - - // MARK: - Content View - - @ViewBuilder - private var contentView: some View { - Form { - searchView - - resultsView - } - } - - // MARK: - Search View - - @ViewBuilder - private var searchView: some View { - Section(L10n.search) { - TextField( - L10n.title, - text: $search.name.coalesce("") - ) - .focused($isTitleFocused) - - TextField( - L10n.originalTitle, - text: $search.originalTitle.coalesce("") - ) - - TextField( - L10n.year, - text: $search.year - .map( - getter: { $0 == nil ? "" : "\($0!)" }, - setter: { Int($0) } - ) - ) - .keyboardType(.numberPad) - } - - if viewModel.state == .searching { - ListRowButton(L10n.cancel) { - viewModel.send(.cancel) - } - .foregroundStyle(.red, .red.opacity(0.2)) - } else { - ListRowButton(L10n.search) { - viewModel.send(.search( - name: search.name, - originalTitle: search.originalTitle, - year: search.year - )) - } - .disabled(search.isEmpty) - .foregroundStyle( - accentColor.overlayColor, - accentColor - ) - } - } - - // MARK: - Results View - - @ViewBuilder - private var resultsView: some View { - if viewModel.searchResults.isNotEmpty { - Section(L10n.items) { - ForEach(viewModel.searchResults) { result in - RemoteSearchResultRow(result: result) { - selectedResult = result - } - } - } - } - } - - // MARK: - Result Image - - @ViewBuilder - static func resultImage(_ url: URL?) -> some View { - ZStack { - Color.clear - - ImageView(url) - .failure { - Image(systemName: "questionmark") - .foregroundStyle(.primary) - } - } - .posterStyle(.portrait) - .posterShadow() - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemEditorView.swift b/jellypig iOS/Views/ItemEditorView/ItemEditorView.swift deleted file mode 100644 index a5812842..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemEditorView.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Factory -import JellyfinAPI -import SwiftUI - -struct ItemEditorView: View { - - @EnvironmentObject - private var router: ItemEditorCoordinator.Router - - @ObservedObject - var viewModel: ItemViewModel - - // MARK: - Can Edit Metadata - - private var canEditMetadata: Bool { - viewModel.userSession.user.permissions.items.canEditMetadata(item: viewModel.item) == true - } - - // MARK: - Can Manage Subtitles - - private var canManageSubtitles: Bool { - viewModel.userSession.user.permissions.items.canManageSubtitles(item: viewModel.item) == true - } - - // MARK: - Can Manage Lyrics - - private var canManageLyrics: Bool { - viewModel.userSession.user.permissions.items.canManageLyrics(item: viewModel.item) == true - } - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .initial, .content, .refreshing: - contentView - case let .error(error): - errorView(with: error) - } - } - .navigationBarTitle(L10n.metadata) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - } - - // MARK: - Content View - - private var contentView: some View { - List { - ListTitleSection( - viewModel.item.name ?? L10n.unknown, - description: viewModel.item.path - ) - - /// Hide metadata options to Lyric/Subtitle only users - if canEditMetadata { - - refreshButtonView - - Section(L10n.edit) { - editMetadataView - editTextView - } - - editComponentsView - } /* else if canManageSubtitles || canManageLyrics { - - // TODO: Enable when Subtitle / Lyric Editing is added - Section(L10n.edit) { - editTextView - } - }*/ - } - } - - // MARK: - ErrorView - - @ViewBuilder - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - } - - // MARK: - Refresh Menu Button - - @ViewBuilder - private var refreshButtonView: some View { - Section { - RefreshMetadataButton(item: viewModel.item) - } footer: { - LearnMoreButton(L10n.metadata) { - TextPair( - title: L10n.findMissing, - subtitle: L10n.findMissingDescription - ) - TextPair( - title: L10n.replaceMetadata, - subtitle: L10n.replaceMetadataDescription - ) - TextPair( - title: L10n.replaceImages, - subtitle: L10n.replaceImagesDescription - ) - TextPair( - title: L10n.replaceAll, - subtitle: L10n.replaceAllDescription - ) - } - } - } - - // MARK: - Editable Metadata Routing Buttons - - @ViewBuilder - private var editMetadataView: some View { - - if let itemKind = viewModel.item.type, - BaseItemKind.itemIdentifiableCases.contains(itemKind) - { - ChevronButton(L10n.identify) { - router.route(to: \.identifyItem, viewModel.item) - } - } - ChevronButton(L10n.images) { - router.route(to: \.editImages, ItemImagesViewModel(item: viewModel.item)) - } - ChevronButton(L10n.metadata) { - router.route(to: \.editMetadata, viewModel.item) - } - } - - // MARK: - Editable Text Routing Buttons - - @ViewBuilder - private var editTextView: some View { - if canManageLyrics { -// ChevronButton(L10n.lyrics) { -// router.route(to: \.editImages, ItemImagesViewModel(item: viewModel.item)) -// } - } - if canManageSubtitles { -// ChevronButton(L10n.subtitles) { -// router.route(to: \.editImages, ItemImagesViewModel(item: viewModel.item)) -// } - } - } - - // MARK: - Editable Metadata Components Routing Buttons - - @ViewBuilder - private var editComponentsView: some View { - Section { - ChevronButton(L10n.genres) { - router.route(to: \.editGenres, viewModel.item) - } - ChevronButton(L10n.people) { - router.route(to: \.editPeople, viewModel.item) - } - ChevronButton(L10n.tags) { - router.route(to: \.editTags, viewModel.item) - } - ChevronButton(L10n.studios) { - router.route(to: \.editStudios, viewModel.item) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemElements/AddItemElementView/AddItemElementView.swift b/jellypig iOS/Views/ItemEditorView/ItemElements/AddItemElementView/AddItemElementView.swift deleted file mode 100644 index 77cf9706..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemElements/AddItemElementView/AddItemElementView.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct AddItemElementView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Environment & Observed Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @ObservedObject - var viewModel: ItemEditorViewModel - - // MARK: - Elements Variables - - let type: ItemArrayElements - - @State - private var id: String? - @State - private var name: String = "" - @State - private var personKind: PersonKind = .unknown - @State - private var personRole: String = "" - - // MARK: - Trie Data Loaded - - @State - private var loaded: Bool = false - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Name is Valid - - private var isValid: Bool { - name.isNotEmpty - } - - // MARK: - Name Already Exists - - private var itemAlreadyExists: Bool { - viewModel.trie.contains(key: name.localizedLowercase) - } - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .initial, .content, .updating: - contentView - case let .error(error): - ErrorView(error: error) - } - } - .navigationTitle(type.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.loading) { - ProgressView() - } - - Button(L10n.save) { - viewModel.send(.add([type.createElement( - name: name, - id: id, - personRole: personRole.isEmpty ? (personKind == .unknown ? nil : personKind.rawValue) : personRole, - personKind: personKind.rawValue - )])) - } - .buttonStyle(.toolbarPill) - .disabled(!isValid) - } - .onFirstAppear { - viewModel.send(.load) - } - .onChange(of: name) { _ in - if !viewModel.backgroundStates.contains(.loading) { - viewModel.send(.search(name)) - } - } - .onReceive(viewModel.events) { event in - switch event { - case .updated: - UIDevice.feedback(.success) - router.dismissCoordinator() - case .loaded: - loaded = true - viewModel.send(.search(name)) - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - } - } - .errorMessage($error) - } - - // MARK: - Content View - - private var contentView: some View { - List { - NameInput( - name: $name, - personKind: $personKind, - personRole: $personRole, - type: type, - itemAlreadyExists: itemAlreadyExists - ) - - SearchResultsSection( - name: $name, - id: $id, - type: type, - population: viewModel.matches, - isSearching: viewModel.backgroundStates.contains(.searching) - ) - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemElements/AddItemElementView/Components/NameInput.swift b/jellypig iOS/Views/ItemEditorView/ItemElements/AddItemElementView/Components/NameInput.swift deleted file mode 100644 index b4ab8fc2..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemElements/AddItemElementView/Components/NameInput.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddItemElementView { - - struct NameInput: View { - - // MARK: - Element Variables - - @Binding - var name: String - @Binding - var personKind: PersonKind - @Binding - var personRole: String - - let type: ItemArrayElements - let itemAlreadyExists: Bool - - // MARK: - Body - - var body: some View { - nameView - - if type == .people { - personView - } - } - - // MARK: - Name View - - private var nameView: some View { - Section { - TextField(L10n.name, text: $name) - .autocorrectionDisabled() - } header: { - Text(L10n.name) - } footer: { - if name.isEmpty || name == "" { - Label( - L10n.required, - systemImage: "exclamationmark.circle.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } else { - if itemAlreadyExists { - Label( - L10n.existsOnServer, - systemImage: "checkmark.circle.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .green)) - } else { - Label( - L10n.willBeCreatedOnServer, - systemImage: "checkmark.seal.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .blue)) - } - } - } - } - - // MARK: - Person View - - var personView: some View { - Section { - Picker(L10n.type, selection: $personKind) { - ForEach(PersonKind.allCases, id: \.self) { kind in - Text(kind.displayTitle).tag(kind) - } - } - if personKind == PersonKind.actor { - TextField(L10n.role, text: $personRole) - .autocorrectionDisabled() - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemElements/AddItemElementView/Components/SearchResultsSection.swift b/jellypig iOS/Views/ItemEditorView/ItemElements/AddItemElementView/Components/SearchResultsSection.swift deleted file mode 100644 index 267359be..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemElements/AddItemElementView/Components/SearchResultsSection.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddItemElementView { - - struct SearchResultsSection: View { - - // MARK: - Element Variables - - @Binding - var name: String - @Binding - var id: String? - - // MARK: - Element Search Variables - - let type: ItemArrayElements - let population: [Element] - - // TODO: Why doesn't environment(\.isSearching) work? - let isSearching: Bool - - // MARK: - Body - - var body: some View { - if name.isNotEmpty { - Section { - if population.isNotEmpty { - resultsView - .animation(.easeInOut, value: population.count) - } else if !isSearching { - noResultsView - .transition(.opacity) - .animation(.easeInOut, value: population.count) - } - } header: { - HStack { - Text(L10n.existingItems) - if isSearching { - DelayedProgressView() - } else { - Text("-") - Text(population.count.description) - } - } - .animation(.easeInOut, value: isSearching) - } - } - } - - // MARK: - No Results View - - private var noResultsView: some View { - Text(L10n.none) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - } - - // MARK: - Results View - - private var resultsView: some View { - ForEach(population, id: \.self) { result in - Button { - name = type.getName(for: result) - id = type.getId(for: result) - } label: { - labelView(result) - } - .foregroundStyle(.primary) - .disabled(name == type.getName(for: result)) - .transition(.opacity.combined(with: .move(edge: .top))) - .animation(.easeInOut, value: population.count) - } - } - - // MARK: - Label View - - @ViewBuilder - private func labelView(_ match: Element) -> some View { - switch type { - case .people: - let person = match as! BaseItemPerson - HStack { - ZStack { - Color.clear - ImageView(person.portraitImageSources(maxWidth: 30)) - .failure { - SystemImageContentView(systemName: "person.fill") - } - } - .posterStyle(.portrait) - .frame(width: 30, height: 90) - .padding(.horizontal) - - Text(type.getName(for: match)) - .frame(maxWidth: .infinity, alignment: .leading) - } - default: - Text(type.getName(for: match)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemElements/EditItemElementView/Components/EditItemElementRow.swift b/jellypig iOS/Views/ItemEditorView/ItemElements/EditItemElementView/Components/EditItemElementRow.swift deleted file mode 100644 index f1d5df9b..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemElements/EditItemElementView/Components/EditItemElementRow.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension EditItemElementView { - - struct EditItemElementRow: View { - - // MARK: - Enviroment Variables - - @Environment(\.isEditing) - var isEditing - @Environment(\.isSelected) - var isSelected - - // MARK: - Metadata Variables - - let item: Element - let type: ItemArrayElements - - // MARK: - Row Actions - - let onSelect: () -> Void - let onDelete: () -> Void - - // MARK: - Body - - var body: some View { - ListRow { - if type == .people { - personImage - } - } content: { - rowContent - } - .onSelect(perform: onSelect) - .isSeparatorVisible(false) - .swipeActions { - Button(L10n.delete, systemImage: "trash", action: onDelete) - .tint(.red) - } - } - - // MARK: - Row Content - - @ViewBuilder - private var rowContent: some View { - HStack { - VStack(alignment: .leading) { - Text(type.getName(for: item)) - .foregroundStyle( - isEditing ? (isSelected ? .primary : .secondary) : .primary - ) - .font(.headline) - .lineLimit(1) - - if type == .people { - let person = (item as! BaseItemPerson) - - TextPairView( - leading: person.type ?? .emptyDash, - trailing: person.role ?? .emptyDash - ) - .foregroundStyle( - isEditing ? (isSelected ? .primary : .secondary) : .primary, - .secondary - ) - .font(.subheadline) - .lineLimit(1) - } - } - - if isEditing { - Spacer() - - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .resizable() - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .foregroundStyle(isSelected ? Color.accentColor : .secondary) - } - } - } - - // MARK: - Person Image - - @ViewBuilder - private var personImage: some View { - let person = (item as! BaseItemPerson) - - ZStack { - Color.clear - - ImageView(person.portraitImageSources(maxWidth: 30)) - .failure { - SystemImageContentView(systemName: "person.fill") - } - } - .posterStyle(.portrait) - .posterShadow() - .frame(width: 30, height: 90) - .padding(.horizontal) - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemElements/EditItemElementView/EditItemElementView.swift b/jellypig iOS/Views/ItemEditorView/ItemElements/EditItemElementView/EditItemElementView.swift deleted file mode 100644 index ff93e5a0..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemElements/EditItemElementView/EditItemElementView.swift +++ /dev/null @@ -1,274 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct EditItemElementView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: ItemEditorCoordinator.Router - - @ObservedObject - var viewModel: ItemEditorViewModel - - // MARK: - Elements - - @State - private var elements: [Element] - - // MARK: - Type & Route - - private let type: ItemArrayElements - private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel) -> Void - - // MARK: - Dialog States - - @State - private var isPresentingDeleteConfirmation = false - @State - private var isPresentingDeleteSelectionConfirmation = false - - // MARK: - Editing States - - @State - private var selectedElements: Set = [] - @State - private var isEditing: Bool = false - @State - private var isReordering: Bool = false - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init( - viewModel: ItemEditorViewModel, - type: ItemArrayElements, - route: @escaping (ItemEditorCoordinator.Router, ItemEditorViewModel) -> Void - ) { - self.viewModel = viewModel - self.type = type - self.route = route - self.elements = type.getElement(for: viewModel.item) - } - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .initial, .content, .updating: - contentView - case let .error(error): - errorView(with: error) - } - } - .navigationBarTitle(type.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isEditing || isReordering) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - if isEditing { - navigationBarSelectView - } - } - ToolbarItem(placement: .topBarTrailing) { - if isEditing || isReordering { - Button(L10n.cancel) { - if isEditing { - isEditing.toggle() - } - if isReordering { - elements = type.getElement(for: viewModel.item) - isReordering.toggle() - } - UIDevice.impact(.light) - selectedElements.removeAll() - } - .buttonStyle(.toolbarPill) - .foregroundStyle(accentColor) - } - } - ToolbarItem(placement: .bottomBar) { - if isEditing { - Button(L10n.delete) { - isPresentingDeleteSelectionConfirmation = true - } - .buttonStyle(.toolbarPill(.red)) - .disabled(selectedElements.isEmpty) - .frame(maxWidth: .infinity, alignment: .trailing) - } - if isReordering { - Button(L10n.save) { - viewModel.send(.reorder(elements)) - isReordering = false - } - .buttonStyle(.toolbarPill) - .disabled(type.getElement(for: viewModel.item) == elements) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.refreshing), - isHidden: isEditing || isReordering - ) { - Button(L10n.add, systemImage: "plus") { - route(router, viewModel) - } - - if elements.isNotEmpty == true { - Button(L10n.edit, systemImage: "checkmark.circle") { - isEditing = true - } - - Button(L10n.reorder, systemImage: "arrow.up.arrow.down") { - isReordering = true - } - } - } - .onReceive(viewModel.events) { events in - switch events { - case let .error(eventError): - error = eventError - default: - break - } - } - .errorMessage($error) - .confirmationDialog( - L10n.delete, - isPresented: $isPresentingDeleteSelectionConfirmation, - titleVisibility: .visible - ) { - deleteSelectedConfirmationActions - } message: { - Text(L10n.deleteSelectedConfirmation) - } - .confirmationDialog( - L10n.delete, - isPresented: $isPresentingDeleteConfirmation, - titleVisibility: .visible - ) { - deleteConfirmationActions - } message: { - Text(L10n.deleteItemConfirmation) - } - .onNotification(.itemMetadataDidChange) { _ in - self.elements = type.getElement(for: self.viewModel.item) - } - } - - // MARK: - Select/Remove All Button - - @ViewBuilder - private var navigationBarSelectView: some View { - let isAllSelected = selectedElements.count == (elements.count) - Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { - selectedElements = isAllSelected ? [] : Set(elements) - } - .buttonStyle(.toolbarPill) - .disabled(!isEditing) - .foregroundStyle(accentColor) - } - - // MARK: - ErrorView - - @ViewBuilder - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.load) - } - } - - // MARK: - Content View - - private var contentView: some View { - List { - InsetGroupedListHeader(type.displayTitle, description: type.description) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.vertical, 24) - - if elements.isNotEmpty { - ForEach(elements, id: \.self) { element in - EditItemElementRow( - item: element, - type: type, - onSelect: { - if isEditing { - selectedElements.toggle(value: element) - } - }, - onDelete: { - selectedElements.toggle(value: element) - isPresentingDeleteConfirmation = true - } - ) - .environment(\.isEditing, isEditing) - .environment(\.isSelected, selectedElements.contains(element)) - .listRowInsets(.edgeInsets) - } - .onMove { source, destination in - guard isReordering else { return } - elements.move(fromOffsets: source, toOffset: destination) - } - } else { - Text(L10n.none) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .listRowSeparator(.hidden) - .listRowInsets(.zero) - } - } - .listStyle(.plain) - .environment(\.editMode, isReordering ? .constant(.active) : .constant(.inactive)) - } - - // MARK: - Delete Selected Confirmation Actions - - @ViewBuilder - private var deleteSelectedConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.confirm, role: .destructive) { - let elementsToRemove = elements.filter { selectedElements.contains($0) } - viewModel.send(.remove(elementsToRemove)) - selectedElements.removeAll() - isEditing = false - } - } - - // MARK: - Delete Single Confirmation Actions - - @ViewBuilder - private var deleteConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.delete, role: .destructive) { - if let elementToRemove = selectedElements.first, selectedElements.count == 1 { - viewModel.send(.remove([elementToRemove])) - selectedElements.removeAll() - isEditing = false - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemImages/AddItemImageView.swift b/jellypig iOS/Views/ItemEditorView/ItemImages/AddItemImageView.swift deleted file mode 100644 index 52ed0fb3..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemImages/AddItemImageView.swift +++ /dev/null @@ -1,214 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import BlurHashKit -import CollectionVGrid -import JellyfinAPI -import SwiftUI - -// TODO: different layouts per image type -// - also based on iOS vs iPadOS - -struct AddItemImageView: View { - - // MARK: - Observed, & Environment Objects - - @EnvironmentObject - private var router: ItemImagesCoordinator.Router - - @ObservedObject - private var viewModel: ItemImagesViewModel - - @StateObject - private var remoteImageInfoViewModel: RemoteImageInfoViewModel - - // MARK: - Dialog State - - @State - private var selectedImage: RemoteImageInfo? - @State - private var error: Error? - - // MARK: - Collection Layout - - @State - private var layout: CollectionVGridLayout = .minWidth(150) - - // MARK: - Initializer - - init(viewModel: ItemImagesViewModel, imageType: ImageType) { - self.viewModel = viewModel - self._remoteImageInfoViewModel = StateObject( - wrappedValue: RemoteImageInfoViewModel( - imageType: imageType, - parent: viewModel.item - ) - ) - } - - // MARK: - Body - - var body: some View { - ZStack { - switch remoteImageInfoViewModel.state { - case .initial, .refreshing: - DelayedProgressView() - case .content: - gridView - case let .error(error): - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - } - } - .animation(.linear(duration: 0.1), value: remoteImageInfoViewModel.state) - .navigationTitle(remoteImageInfoViewModel.imageType.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(viewModel.backgroundStates.contains(.updating)) - .navigationBarMenuButton(isLoading: viewModel.backgroundStates.contains(.updating)) { - Button { - remoteImageInfoViewModel.includeAllLanguages.toggle() - } label: { - if remoteImageInfoViewModel.includeAllLanguages { - Label(L10n.allLanguages, systemImage: "checkmark") - } else { - Text(L10n.allLanguages) - } - } - - if remoteImageInfoViewModel.providers.isNotEmpty { - Menu { - Button { - remoteImageInfoViewModel.provider = nil - } label: { - if remoteImageInfoViewModel.provider == nil { - Label(L10n.all, systemImage: "checkmark") - } else { - Text(L10n.all) - } - } - - ForEach(remoteImageInfoViewModel.providers, id: \.self) { provider in - Button { - remoteImageInfoViewModel.provider = provider - } label: { - if remoteImageInfoViewModel.provider == provider { - Label(provider, systemImage: "checkmark") - } else { - Text(provider) - } - } - } - } label: { - Text(L10n.provider) - - Text(remoteImageInfoViewModel.provider ?? L10n.all) - } - } - } - .sheet(item: $selectedImage) { - selectedImage = nil - } content: { remoteImageInfo in - ItemImageDetailsView( - viewModel: viewModel, - imageSource: ImageSource(url: remoteImageInfo.url?.url), - width: remoteImageInfo.width, - height: remoteImageInfo.height, - language: remoteImageInfo.language, - provider: remoteImageInfo.providerName, - rating: remoteImageInfo.communityRating, - ratingVotes: remoteImageInfo.voteCount, - onClose: { - selectedImage = nil - }, - onSave: { - viewModel.send(.setImage(remoteImageInfo)) - selectedImage = nil - } - ) - } - .onFirstAppear { - remoteImageInfoViewModel.send(.refresh) - } - .onReceive(viewModel.events) { event in - switch event { - case .updated: - UIDevice.feedback(.success) - router.pop() - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - } - } - .errorMessage($error) - } - - // MARK: - Content Grid View - - @ViewBuilder - private var gridView: some View { - if remoteImageInfoViewModel.elements.isEmpty { - Text(L10n.none) - } else { - CollectionVGrid( - uniqueElements: remoteImageInfoViewModel.elements, - layout: layout - ) { image in - imageButton(image) - } - .onReachedBottomEdge(offset: .offset(300)) { - remoteImageInfoViewModel.send(.getNextPage) - } - } - } - - // MARK: - Poster Image Button - - @ViewBuilder - private func imageButton(_ image: RemoteImageInfo) -> some View { - Button { - selectedImage = image - } label: { - posterImage( - image, - posterStyle: (image.height ?? 0) > (image.width ?? 0) ? .portrait : .landscape - ) - } - } - - // MARK: - Poster Image - - @ViewBuilder - private func posterImage( - _ posterImageInfo: RemoteImageInfo?, - posterStyle: PosterDisplayType - ) -> some View { - ZStack { - Color.secondarySystemFill - .frame(maxWidth: .infinity, maxHeight: .infinity) - - ImageView(posterImageInfo?.url?.url) - .placeholder { source in - if let blurHash = source.blurHash { - BlurHashView(blurHash: blurHash) - .scaledToFit() - } else { - Image(systemName: "photo") - } - } - .failure { - Image(systemName: "photo") - } - .pipeline(.Swiftfin.other) - .foregroundStyle(.secondary) - .font(.headline) - } - .posterStyle(posterStyle) - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift b/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift deleted file mode 100644 index f762d4e2..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDeleteButton.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemImageDetailsView { - - struct DeleteButton: View { - - // MARK: - Delete Action - - let onDelete: () -> Void - - // MARK: - Dialog State - - @State - private var isPresentingConfirmation: Bool = false - - // MARK: - Body - - var body: some View { - ListRowButton(L10n.delete, role: .destructive) { - isPresentingConfirmation = true - } - .confirmationDialog( - L10n.delete, - isPresented: $isPresentingConfirmation, - titleVisibility: .visible - ) { - Button( - L10n.delete, - role: .destructive, - action: onDelete - ) - - Button(L10n.cancel, role: .cancel) { - isPresentingConfirmation = false - } - } message: { - Text(L10n.deleteItemConfirmationMessage) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift b/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift deleted file mode 100644 index c5302a68..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsDetailsSection.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemImageDetailsView { - - struct DetailsSection: View { - - // MARK: - Image Details Variables - - private let index: Int? - private let language: String? - private let width: Int? - private let height: Int? - private let provider: String? - - // MARK: - Image Ratings Variables - - private let rating: Double? - private let ratingVotes: Int? - - // MARK: - Image Source Variable - - private let url: URL? - - // MARK: - Initializer - - init( - url: URL? = nil, - index: Int? = nil, - language: String? = nil, - width: Int? = nil, - height: Int? = nil, - provider: String? = nil, - rating: Double? = nil, - ratingType: RatingType? = nil, - ratingVotes: Int? = nil - ) { - self.url = url - self.index = index - self.language = language - self.width = width - self.height = height - self.provider = provider - self.rating = rating - self.ratingVotes = ratingVotes - } - - // MARK: - Body - - var body: some View { - Section(L10n.details) { - if let provider { - TextPairView(leading: L10n.provider, trailing: provider) - } - - if let language { - TextPairView(leading: L10n.language, trailing: language) - } - - if let width, let height { - TextPairView( - leading: L10n.dimensions, - trailing: "\(width) x \(height)" - ) - } - - if let index { - TextPairView(leading: L10n.index, trailing: index.description) - } - } - - if let rating { - Section(L10n.ratings) { - TextPairView(leading: L10n.rating, trailing: rating.formatted(.number.precision(.fractionLength(2)))) - - if let ratingVotes { - TextPairView(L10n.votes, value: Text(ratingVotes, format: .number)) - } - } - } - - if let url { - Section { - ChevronButton( - L10n.imageSource, - external: true - ) { - UIApplication.shared.open(url) - } - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift b/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift deleted file mode 100644 index 6fc1c89f..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemImageDetailsView { - - struct HeaderSection: View { - - // MARK: - Image Info - - let imageSource: ImageSource - let posterType: PosterDisplayType - - // MARK: - Body - - var body: some View { - Section { - ImageView(imageSource) - .placeholder { _ in - Image(systemName: "photo") - } - .failure { - Image(systemName: "photo") - } - .pipeline(.Swiftfin.other) - } - .scaledToFit() - .frame(maxHeight: 300) - .posterStyle(posterType) - .frame(maxWidth: .infinity) - .listRowBackground(Color.clear) - .listRowCornerRadius(0) - .listRowInsets(.zero) - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift b/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift deleted file mode 100644 index 1cabcaf8..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct ItemImageDetailsView: View { - - @Environment(\.isEditing) - private var isEditing - - // MARK: - State, Observed, & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @ObservedObject - private var viewModel: ItemImagesViewModel - - // MARK: - Image Variable - - private let imageSource: ImageSource - - // MARK: - Description Variables - - private let index: Int? - private let width: Int? - private let height: Int? - private let language: String? - private let provider: String? - private let rating: Double? - private let ratingVotes: Int? - - // MARK: - Image Actions - - private let onClose: () -> Void - private let onSave: (() -> Void)? - private let onDelete: (() -> Void)? - - // MARK: - Initializer - - init( - viewModel: ItemImagesViewModel, - imageSource: ImageSource, - index: Int? = nil, - width: Int? = nil, - height: Int? = nil, - language: String? = nil, - provider: String? = nil, - rating: Double? = nil, - ratingVotes: Int? = nil, - onClose: @escaping () -> Void, - onSave: (() -> Void)? = nil, - onDelete: (() -> Void)? = nil - ) { - self.viewModel = viewModel - self.imageSource = imageSource - self.index = index - self.width = width - self.height = height - self.language = language - self.provider = provider - self.rating = rating - self.ratingVotes = ratingVotes - self.onClose = onClose - self.onSave = onSave - self.onDelete = onDelete - } - - // MARK: - Body - - var body: some View { - NavigationView { - contentView - .navigationTitle(L10n.image) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - onClose() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.updating) { - ProgressView() - } - - if let onSave { - Button(L10n.save, action: onSave) - .buttonStyle(.toolbarPill) - } - } - } - } - - // MARK: - Content View - - @ViewBuilder - private var contentView: some View { - List { - HeaderSection( - imageSource: imageSource, - posterType: height ?? 0 > width ?? 0 ? .portrait : .landscape - ) - - DetailsSection( - url: imageSource.url, - index: index, - language: language, - width: width, - height: height, - provider: provider, - rating: rating, - ratingVotes: ratingVotes - ) - - if isEditing, let onDelete { - DeleteButton { - onDelete() - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImagesView.swift b/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImagesView.swift deleted file mode 100644 index bbe63e6d..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemImagesView.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct ItemImagesView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: ItemImagesCoordinator.Router - - @StateObject - var viewModel: ItemImagesViewModel - - // MARK: - Dialog State - - @State - private var selectedImage: ImageInfo? - @State - private var selectedType: ImageType? - @State - private var isFilePickerPresented = false - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - imageView - case .initial: - DelayedProgressView() - case let .error(error): - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - } - } - .navigationTitle(L10n.images) - .navigationBarTitleDisplayMode(.inline) - .onFirstAppear { - viewModel.send(.refresh) - } - .navigationBarCloseButton { - router.dismissCoordinator() - } - .sheet(item: $selectedImage) { - selectedImage = nil - } content: { imageInfo in - ItemImageDetailsView( - viewModel: viewModel, - imageSource: imageInfo.itemImageSource( - itemID: viewModel.item.id!, - client: viewModel.userSession.client - ), - index: imageInfo.imageIndex, - width: imageInfo.width, - height: imageInfo.height, - onClose: { - selectedImage = nil - }, - onDelete: { - viewModel.send(.deleteImage(imageInfo)) - selectedImage = nil - } - ) - .environment(\.isEditing, true) - } - .fileImporter( - isPresented: $isFilePickerPresented, - allowedContentTypes: [.png, .jpeg, .heic], - allowsMultipleSelection: false - ) { - switch $0 { - case let .success(urls): - if let file = urls.first, let type = selectedType { - viewModel.send(.uploadFile(file: file, type: type)) - selectedType = nil - } - case let .failure(fileError): - error = fileError - selectedType = nil - } - } - .onReceive(viewModel.events) { event in - switch event { - case .updated: () - case let .error(eventError): - self.error = eventError - } - } - .errorMessage($error) - } - - // MARK: - Image View - - @ViewBuilder - private var imageView: some View { - ScrollView { - ForEach(ImageType.allCases.sorted(using: \.rawValue), id: \.self) { imageType in - Section { - imageScrollView(for: imageType) - - RowDivider() - .padding(.vertical, 16) - } header: { - sectionHeader(for: imageType) - } - } - } - } - - // MARK: - Image Scroll View - - @ViewBuilder - private func imageScrollView(for imageType: ImageType) -> some View { - let images = viewModel.images[imageType] ?? [] - - if images.isNotEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(images, id: \.self) { imageInfo in - imageButton(imageInfo: imageInfo) { - selectedImage = imageInfo - } - } - } - .edgePadding(.horizontal) - } - } - } - - // MARK: - Section Header - - @ViewBuilder - private func sectionHeader(for imageType: ImageType) -> some View { - HStack { - Text(imageType.displayTitle) - .font(.headline) - - Spacer() - - Menu(L10n.options, systemImage: "plus") { - Button(L10n.search, systemImage: "magnifyingglass") { - router.route( - to: \.addImage, - imageType - ) - } - - Divider() - - Button(L10n.uploadFile, systemImage: "document.badge.plus") { - selectedType = imageType - isFilePickerPresented = true - } - - Button(L10n.uploadPhoto, systemImage: "photo.badge.plus") { - router.route(to: \.photoPicker, imageType) - } - } - .font(.body) - .labelStyle(.iconOnly) - .backport - .fontWeight(.semibold) - .foregroundStyle(accentColor) - } - .edgePadding(.horizontal) - } - - // MARK: - Image Button - - // TODO: instead of using `posterStyle`, should be sized based on - // the image type and just ignore and poster styling - @ViewBuilder - private func imageButton( - imageInfo: ImageInfo, - onSelect: @escaping () -> Void - ) -> some View { - Button(action: onSelect) { - ZStack { - Color.secondarySystemFill - - ImageView( - imageInfo.itemImageSource( - itemID: viewModel.item.id!, - client: viewModel.userSession.client - ) - ) - .placeholder { _ in - Image(systemName: "photo") - } - .failure { - Image(systemName: "photo") - } - .pipeline(.Swiftfin.other) - } - .posterStyle(imageInfo.height ?? 0 > imageInfo.width ?? 0 ? .portrait : .landscape) - .frame(maxHeight: 150) - .posterShadow() - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift b/jellypig iOS/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift deleted file mode 100644 index 72716c35..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/Components/ItemPhotoCropView.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import Mantis -import SwiftUI - -struct ItemPhotoCropView: View { - - // MARK: - State, Observed, & Environment Objects - - @EnvironmentObject - private var router: ItemImagePickerCoordinator.Router - - @ObservedObject - var viewModel: ItemImagesViewModel - - // MARK: - Image Variable - - let image: UIImage - let type: ImageType - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Body - - var body: some View { - PhotoCropView( - isSaving: viewModel.backgroundStates.contains(.updating), - image: image, - cropShape: .rect, - presetRatio: .canUseMultiplePresetFixedRatio() - ) { - viewModel.send(.uploadImage(image: $0, type: type)) - } onCancel: { - router.dismissCoordinator() - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .interactiveDismissDisabled(viewModel.backgroundStates.contains(.updating)) - .navigationBarBackButtonHidden(viewModel.backgroundStates.contains(.updating)) - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - error = eventError - case .updated: - router.dismissCoordinator() - } - } - .errorMessage($error) - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift b/jellypig iOS/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift deleted file mode 100644 index 5fa611fa..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemImages/ItemPhotoPickerView/ItemPhotoPickerView.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct ItemImagePicker: View { - - // MARK: - Observed, & Environment Objects - - @EnvironmentObject - private var router: ItemImagePickerCoordinator.Router - - // MARK: - Body - - var body: some View { - PhotoPickerView { - router.route(to: \.cropImage, $0) - } onCancel: { - router.dismissCoordinator() - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift deleted file mode 100644 index e6da73db..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/AddItemElementView/AddItemElementView.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct AddItemElementView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Environment & Observed Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @ObservedObject - var viewModel: ItemEditorViewModel - - // MARK: - Elements Variables - - let type: ItemArrayElements - - @State - private var id: String? - @State - private var name: String = "" - @State - private var personKind: PersonKind = .unknown - @State - private var personRole: String = "" - - // MARK: - Trie Data Loaded - - @State - private var loaded: Bool = false - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Name is Valid - - private var isValid: Bool { - name.isNotEmpty - } - - // MARK: - Name Already Exists - - private var itemAlreadyExists: Bool { - viewModel.trie.contains(key: name.localizedLowercase) - } - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .initial, .content, .updating: - contentView - case let .error(error): - ErrorView(error: error) - } - } - .navigationTitle(type.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .topBarTrailing { - if viewModel.backgroundStates.contains(.loading) { - ProgressView() - } - - Button(L10n.save) { - viewModel.send(.add([type.createElement( - name: name, - id: id, - personRole: personRole.isEmpty ? (personKind == .unknown ? nil : personKind.rawValue) : personRole, - personKind: personKind - )])) - } - .buttonStyle(.toolbarPill) - .disabled(!isValid) - } - .onFirstAppear { - viewModel.send(.load) - } - .onChange(of: name) { _ in - if !viewModel.backgroundStates.contains(.loading) { - viewModel.send(.search(name)) - } - } - .onReceive(viewModel.events) { event in - switch event { - case .updated: - UIDevice.feedback(.success) - router.dismissCoordinator() - case .loaded: - loaded = true - viewModel.send(.search(name)) - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - } - } - .errorMessage($error) - } - - // MARK: - Content View - - private var contentView: some View { - List { - NameInput( - name: $name, - personKind: $personKind, - personRole: $personRole, - type: type, - itemAlreadyExists: itemAlreadyExists - ) - - SearchResultsSection( - name: $name, - id: $id, - type: type, - population: viewModel.matches, - isSearching: viewModel.backgroundStates.contains(.searching) - ) - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift deleted file mode 100644 index b4ab8fc2..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/NameInput.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddItemElementView { - - struct NameInput: View { - - // MARK: - Element Variables - - @Binding - var name: String - @Binding - var personKind: PersonKind - @Binding - var personRole: String - - let type: ItemArrayElements - let itemAlreadyExists: Bool - - // MARK: - Body - - var body: some View { - nameView - - if type == .people { - personView - } - } - - // MARK: - Name View - - private var nameView: some View { - Section { - TextField(L10n.name, text: $name) - .autocorrectionDisabled() - } header: { - Text(L10n.name) - } footer: { - if name.isEmpty || name == "" { - Label( - L10n.required, - systemImage: "exclamationmark.circle.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } else { - if itemAlreadyExists { - Label( - L10n.existsOnServer, - systemImage: "checkmark.circle.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .green)) - } else { - Label( - L10n.willBeCreatedOnServer, - systemImage: "checkmark.seal.fill" - ) - .labelStyle(.sectionFooterWithImage(imageStyle: .blue)) - } - } - } - } - - // MARK: - Person View - - var personView: some View { - Section { - Picker(L10n.type, selection: $personKind) { - ForEach(PersonKind.allCases, id: \.self) { kind in - Text(kind.displayTitle).tag(kind) - } - } - if personKind == PersonKind.actor { - TextField(L10n.role, text: $personRole) - .autocorrectionDisabled() - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift deleted file mode 100644 index ea49d2ae..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension AddItemElementView { - - struct SearchResultsSection: View { - - // MARK: - Element Variables - - @Binding - var name: String - @Binding - var id: String? - - // MARK: - Element Search Variables - - let type: ItemArrayElements - let population: [Element] - let isSearching: Bool - - // MARK: - Body - - var body: some View { - if name.isNotEmpty { - Section { - if population.isNotEmpty { - resultsView - .animation(.easeInOut, value: population.count) - } else if !isSearching { - noResultsView - .transition(.opacity) - .animation(.easeInOut, value: population.count) - } - } header: { - HStack { - Text(L10n.existingItems) - if isSearching { - DelayedProgressView() - } else { - Text("-") - Text(population.count.description) - } - } - .animation(.easeInOut, value: isSearching) - } - } - } - - // MARK: - No Results View - - private var noResultsView: some View { - Text(L10n.none) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - } - - // MARK: - Results View - - private var resultsView: some View { - ForEach(population, id: \.self) { result in - Button { - name = type.getName(for: result) - id = type.getId(for: result) - } label: { - labelView(result) - } - .foregroundStyle(.primary) - .disabled(name == type.getName(for: result)) - .transition(.opacity.combined(with: .move(edge: .top))) - .animation(.easeInOut, value: population.count) - } - } - - // MARK: - Label View - - @ViewBuilder - private func labelView(_ match: Element) -> some View { - switch type { - case .people: - let person = match as! BaseItemPerson - HStack { - ZStack { - Color.clear - ImageView(person.portraitImageSources(maxWidth: 30)) - .failure { - SystemImageContentView(systemName: "person.fill") - } - } - .posterStyle(.portrait) - .frame(width: 30, height: 90) - .padding(.horizontal) - - Text(type.getName(for: match)) - .frame(maxWidth: .infinity, alignment: .leading) - } - default: - Text(type.getName(for: match)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift deleted file mode 100644 index 7af18a01..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension EditItemElementView { - - struct EditItemElementRow: View { - - // MARK: - Enviroment Variables - - @Environment(\.isEditing) - var isEditing - @Environment(\.isSelected) - var isSelected - - // MARK: - Metadata Variables - - let item: Element - let type: ItemArrayElements - - // MARK: - Row Actions - - let onSelect: () -> Void - let onDelete: () -> Void - - // MARK: - Body - - var body: some View { - ListRow { - if type == .people { - personImage - } - } content: { - rowContent - } - .onSelect(perform: onSelect) - .isSeparatorVisible(false) - .swipeActions { - Button(L10n.delete, systemImage: "trash", action: onDelete) - .tint(.red) - } - } - - // MARK: - Row Content - - @ViewBuilder - private var rowContent: some View { - HStack { - VStack(alignment: .leading) { - Text(type.getName(for: item)) - .foregroundStyle( - isEditing ? (isSelected ? .primary : .secondary) : .primary - ) - .font(.headline) - .lineLimit(1) - - if type == .people { - let person = (item as! BaseItemPerson) - - TextPairView( - leading: person.type?.displayTitle ?? .emptyDash, - trailing: person.role ?? .emptyDash - ) - .foregroundStyle( - isEditing ? (isSelected ? .primary : .secondary) : .primary, - .secondary - ) - .font(.subheadline) - .lineLimit(1) - } - } - - if isEditing { - Spacer() - - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .resizable() - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .foregroundStyle(isSelected ? Color.accentColor : .secondary) - } - } - } - - // MARK: - Person Image - - @ViewBuilder - private var personImage: some View { - let person = (item as! BaseItemPerson) - - ZStack { - Color.clear - - ImageView(person.portraitImageSources(maxWidth: 30)) - .failure { - SystemImageContentView(systemName: "person.fill") - } - } - .posterStyle(.portrait) - .posterShadow() - .frame(width: 30, height: 90) - .padding(.horizontal) - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift deleted file mode 100644 index ff93e5a0..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditItemElementView/EditItemElementView.swift +++ /dev/null @@ -1,274 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct EditItemElementView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: ItemEditorCoordinator.Router - - @ObservedObject - var viewModel: ItemEditorViewModel - - // MARK: - Elements - - @State - private var elements: [Element] - - // MARK: - Type & Route - - private let type: ItemArrayElements - private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel) -> Void - - // MARK: - Dialog States - - @State - private var isPresentingDeleteConfirmation = false - @State - private var isPresentingDeleteSelectionConfirmation = false - - // MARK: - Editing States - - @State - private var selectedElements: Set = [] - @State - private var isEditing: Bool = false - @State - private var isReordering: Bool = false - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init( - viewModel: ItemEditorViewModel, - type: ItemArrayElements, - route: @escaping (ItemEditorCoordinator.Router, ItemEditorViewModel) -> Void - ) { - self.viewModel = viewModel - self.type = type - self.route = route - self.elements = type.getElement(for: viewModel.item) - } - - // MARK: - Body - - var body: some View { - ZStack { - switch viewModel.state { - case .initial, .content, .updating: - contentView - case let .error(error): - errorView(with: error) - } - } - .navigationBarTitle(type.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(isEditing || isReordering) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - if isEditing { - navigationBarSelectView - } - } - ToolbarItem(placement: .topBarTrailing) { - if isEditing || isReordering { - Button(L10n.cancel) { - if isEditing { - isEditing.toggle() - } - if isReordering { - elements = type.getElement(for: viewModel.item) - isReordering.toggle() - } - UIDevice.impact(.light) - selectedElements.removeAll() - } - .buttonStyle(.toolbarPill) - .foregroundStyle(accentColor) - } - } - ToolbarItem(placement: .bottomBar) { - if isEditing { - Button(L10n.delete) { - isPresentingDeleteSelectionConfirmation = true - } - .buttonStyle(.toolbarPill(.red)) - .disabled(selectedElements.isEmpty) - .frame(maxWidth: .infinity, alignment: .trailing) - } - if isReordering { - Button(L10n.save) { - viewModel.send(.reorder(elements)) - isReordering = false - } - .buttonStyle(.toolbarPill) - .disabled(type.getElement(for: viewModel.item) == elements) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.refreshing), - isHidden: isEditing || isReordering - ) { - Button(L10n.add, systemImage: "plus") { - route(router, viewModel) - } - - if elements.isNotEmpty == true { - Button(L10n.edit, systemImage: "checkmark.circle") { - isEditing = true - } - - Button(L10n.reorder, systemImage: "arrow.up.arrow.down") { - isReordering = true - } - } - } - .onReceive(viewModel.events) { events in - switch events { - case let .error(eventError): - error = eventError - default: - break - } - } - .errorMessage($error) - .confirmationDialog( - L10n.delete, - isPresented: $isPresentingDeleteSelectionConfirmation, - titleVisibility: .visible - ) { - deleteSelectedConfirmationActions - } message: { - Text(L10n.deleteSelectedConfirmation) - } - .confirmationDialog( - L10n.delete, - isPresented: $isPresentingDeleteConfirmation, - titleVisibility: .visible - ) { - deleteConfirmationActions - } message: { - Text(L10n.deleteItemConfirmation) - } - .onNotification(.itemMetadataDidChange) { _ in - self.elements = type.getElement(for: self.viewModel.item) - } - } - - // MARK: - Select/Remove All Button - - @ViewBuilder - private var navigationBarSelectView: some View { - let isAllSelected = selectedElements.count == (elements.count) - Button(isAllSelected ? L10n.removeAll : L10n.selectAll) { - selectedElements = isAllSelected ? [] : Set(elements) - } - .buttonStyle(.toolbarPill) - .disabled(!isEditing) - .foregroundStyle(accentColor) - } - - // MARK: - ErrorView - - @ViewBuilder - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.load) - } - } - - // MARK: - Content View - - private var contentView: some View { - List { - InsetGroupedListHeader(type.displayTitle, description: type.description) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .padding(.vertical, 24) - - if elements.isNotEmpty { - ForEach(elements, id: \.self) { element in - EditItemElementRow( - item: element, - type: type, - onSelect: { - if isEditing { - selectedElements.toggle(value: element) - } - }, - onDelete: { - selectedElements.toggle(value: element) - isPresentingDeleteConfirmation = true - } - ) - .environment(\.isEditing, isEditing) - .environment(\.isSelected, selectedElements.contains(element)) - .listRowInsets(.edgeInsets) - } - .onMove { source, destination in - guard isReordering else { return } - elements.move(fromOffsets: source, toOffset: destination) - } - } else { - Text(L10n.none) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .listRowSeparator(.hidden) - .listRowInsets(.zero) - } - } - .listStyle(.plain) - .environment(\.editMode, isReordering ? .constant(.active) : .constant(.inactive)) - } - - // MARK: - Delete Selected Confirmation Actions - - @ViewBuilder - private var deleteSelectedConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.confirm, role: .destructive) { - let elementsToRemove = elements.filter { selectedElements.contains($0) } - viewModel.send(.remove(elementsToRemove)) - selectedElements.removeAll() - isEditing = false - } - } - - // MARK: - Delete Single Confirmation Actions - - @ViewBuilder - private var deleteConfirmationActions: some View { - Button(L10n.cancel, role: .cancel) {} - - Button(L10n.delete, role: .destructive) { - if let elementToRemove = selectedElements.first, selectedElements.count == 1 { - viewModel.send(.remove([elementToRemove])) - selectedElements.removeAll() - isEditing = false - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DateSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DateSection.swift deleted file mode 100644 index d70a4e46..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DateSection.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct DateSection: View { - - @Binding - var item: BaseItemDto - - let itemType: BaseItemKind - - var body: some View { - Section(L10n.dates) { - DatePicker( - L10n.dateAdded, - selection: $item.dateCreated.coalesce(.now), - displayedComponents: .date - ) - - DatePicker( - itemType == .person ? L10n.birthday : L10n.releaseDate, - selection: $item.premiereDate.coalesce(.now), - displayedComponents: .date - ) - - if itemType == .series || itemType == .person { - DatePicker( - itemType == .person ? L10n.dateOfDeath : L10n.endDate, - selection: $item.endDate.coalesce(.now), - displayedComponents: .date - ) - } - } - - Section(L10n.year) { - TextField( - itemType == .person ? L10n.birthYear : L10n.year, - value: $item.productionYear, - format: .number.grouping(.never) - ) - .keyboardType(.numberPad) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DisplayOrderSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DisplayOrderSection.swift deleted file mode 100644 index 2ea9b720..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/DisplayOrderSection.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct DisplayOrderSection: View { - - @Binding - var item: BaseItemDto - - let itemType: BaseItemKind - - var body: some View { - Section(L10n.displayOrder) { - switch itemType { - case .boxSet: - Picker( - L10n.displayOrder, - selection: $item.displayOrder - .coalesce("") - .map( - getter: { BoxSetDisplayOrder(rawValue: $0) ?? .dateModified }, - setter: { $0.rawValue } - ) - ) { - ForEach(BoxSetDisplayOrder.allCases) { order in - Text(order.displayTitle).tag(order) - } - } - - case .series: - Picker( - L10n.displayOrder, - selection: $item.displayOrder - .coalesce("") - .map( - getter: { SeriesDisplayOrder(rawValue: $0) ?? .aired }, - setter: { $0.rawValue } - ) - ) { - ForEach(SeriesDisplayOrder.allCases) { order in - Text(order.displayTitle).tag(order) - } - } - - default: - EmptyView() - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/EpisodeSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/EpisodeSection.swift deleted file mode 100644 index 73c6b5f5..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/EpisodeSection.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct EpisodeSection: View { - - @Binding - var item: BaseItemDto - - // MARK: - Body - - var body: some View { - Section(L10n.season) { - - // MARK: - Season Number - - ChevronButton( - L10n.season, - subtitle: item.parentIndexNumber?.description, - description: L10n.enterSeasonNumber - ) { - TextField( - L10n.season, - value: $item.parentIndexNumber, - format: .number - ) - .keyboardType(.numberPad) - } - - // MARK: - Episode Number - - ChevronButton( - L10n.episode, - subtitle: item.indexNumber?.description, - description: L10n.enterEpisodeNumber - ) { - TextField( - L10n.episode, - value: $item.indexNumber, - format: .number - ) - .keyboardType(.numberPad) - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LocalizationSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LocalizationSection.swift deleted file mode 100644 index 854935c4..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LocalizationSection.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct LocalizationSection: View { - - @Binding - var item: BaseItemDto - - var body: some View { - Section(L10n.metadataPreferences) { - LanguagePicker( - title: L10n.language, - selectedLanguageCode: $item.preferredMetadataLanguage - ) - - CountryPicker( - title: L10n.country, - selectedCountryCode: $item.preferredMetadataCountryCode - ) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LockMetadataSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LockMetadataSection.swift deleted file mode 100644 index ee2b24ae..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/LockMetadataSection.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct LockMetadataSection: View { - - @Binding - var item: BaseItemDto - - var body: some View { - Section(L10n.lockedFields) { - Toggle( - L10n.lockAllFields, - isOn: $item.lockData.coalesce(false) - ) - } - - if item.lockData != true { - Section { - ForEach(MetadataField.allCases, id: \.self) { field in - Toggle( - field.displayTitle, - isOn: $item.lockedFields - .coalesce([]) - .contains(field) - ) - } - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift deleted file mode 100644 index c9a580f8..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct MediaFormatSection: View { - - @Binding - var item: BaseItemDto - - var body: some View { - Section(L10n.format) { - TextField( - L10n.originalAspectRatio, - value: $item.aspectRatio, - format: .nilIfEmptyString - ) - - Video3DFormatPicker( - title: L10n.format3D, - selectedFormat: $item.video3DFormat - ) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/OverviewSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/OverviewSection.swift deleted file mode 100644 index a8c8f27e..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/OverviewSection.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct OverviewSection: View { - - // MARK: - Metadata Variables - - @Binding - var item: BaseItemDto - - let itemType: BaseItemKind - - // MARK: - Show Tagline - - private var showTaglines: Bool { - [ - BaseItemKind.movie, - .series, - .audioBook, - .book, - .audio, - ].contains(itemType) - } - - // MARK: - Body - - var body: some View { - if showTaglines { - // There doesn't seem to be a usage anywhere of more than 1 tagline? - Section(L10n.taglines) { - TextField( - L10n.tagline, - value: $item.taglines - .map( - getter: { $0 == nil ? "" : $0!.first }, - setter: { $0 == nil ? [] : [$0!] } - ), - format: .nilIfEmptyString - ) - } - } - - Section(L10n.overview) { - TextEditor(text: $item.overview.coalesce("")) - .onAppear { - // Workaround for iOS 17 and earlier bug - // where the row height won't be set properly - item.overview = item.overview - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ParentialRatingsSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ParentialRatingsSection.swift deleted file mode 100644 index ed5b324e..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ParentialRatingsSection.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct ParentalRatingSection: View { - - // MARK: - Observed Object - - @ObservedObject - private var viewModel = ParentalRatingsViewModel() - - // MARK: - Item - - @Binding - var item: BaseItemDto - - // MARK: - Ratings States - - @State - private var officialRatings: [ParentalRating] = [] - @State - private var customRatings: [ParentalRating] = [] - - // MARK: - Body - - var body: some View { - Section(L10n.parentalRating) { - - // MARK: - Official Rating Picker - - Picker( - L10n.officialRating, - selection: $item.officialRating - .map( - getter: { value in officialRatings.first { $0.name == value } }, - setter: { $0?.name } - ) - ) { - Text(L10n.none).tag(nil as ParentalRating?) - ForEach(officialRatings, id: \.self) { rating in - Text(rating.name ?? "").tag(rating as ParentalRating?) - } - } - .onAppear { - updateOfficialRatings() - } - .onChange(of: viewModel.parentalRatings) { _ in - updateOfficialRatings() - } - - // MARK: - Custom Rating Picker - - Picker( - L10n.customRating, - selection: $item.customRating - .map( - getter: { value in customRatings.first { $0.name == value } }, - setter: { $0?.name } - ) - ) { - Text(L10n.none).tag(nil as ParentalRating?) - ForEach(customRatings, id: \.self) { rating in - Text(rating.name ?? "").tag(rating as ParentalRating?) - } - } - .onAppear { - updateCustomRatings() - } - .onChange(of: viewModel.parentalRatings) { _ in - updateCustomRatings() - } - } - .onFirstAppear { - viewModel.send(.refresh) - } - } - - // MARK: - Update Official Rating - - private func updateOfficialRatings() { - officialRatings = viewModel.parentalRatings - if let currentRatingName = item.officialRating, - !officialRatings.contains(where: { $0.name == currentRatingName }) - { - officialRatings.append(ParentalRating(name: currentRatingName)) - } - } - - // MARK: - Update Custom Rating - - private func updateCustomRatings() { - customRatings = viewModel.parentalRatings - if let currentRatingName = item.customRating, - !customRatings.contains(where: { $0.name == currentRatingName }) - { - customRatings.append(ParentalRating(name: currentRatingName)) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ReviewsSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ReviewsSection.swift deleted file mode 100644 index ea98554e..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/ReviewsSection.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct ReviewsSection: View { - - @Binding - var item: BaseItemDto - - // MARK: - Body - - var body: some View { - Section(L10n.reviews) { - - // MARK: - Critics Rating - - ChevronButton( - L10n.critics, - subtitle: item.criticRating.map { "\($0)" } ?? .emptyDash, - description: L10n.ratingDescription(L10n.critics) - ) { - TextField( - L10n.rating, - value: $item.criticRating, - format: .number.precision(.fractionLength(1)) - ) - .keyboardType(.decimalPad) - .onChange(of: item.criticRating) { _ in - if let rating = item.criticRating { - item.criticRating = min(max(rating, 0), 10) - } - } - } - - // MARK: - Community Rating - - ChevronButton( - L10n.community, - subtitle: item.communityRating.map { "\($0)" } ?? .emptyDash, - description: L10n.ratingDescription(L10n.community) - ) { - TextField( - L10n.rating, - value: $item.communityRating, - format: .number.precision(.fractionLength(1)) - ) - .keyboardType(.decimalPad) - .onChange(of: item.communityRating) { _ in - if let rating = item.communityRating { - item.communityRating = min(max(rating, 0), 10) - } - } - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/SeriesSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/SeriesSection.swift deleted file mode 100644 index 9a516287..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/SeriesSection.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct SeriesSection: View { - - @Binding - private var item: BaseItemDto - - @State - private var tempRunTime: Int? - - // MARK: - Initializer - - init(item: Binding) { - self._item = item - self.tempRunTime = Int(ServerTicks(item.wrappedValue.runTimeTicks ?? 0).minutes) - } - - // MARK: - Body - - var body: some View { - - Section(L10n.series) { - seriesStatusView - } - - Section(L10n.episodes) { - airTimeView - - runTimeView - } - - Section(L10n.dayOfWeek) { - airDaysView - } - } - - // MARK: - Series Status View - - @ViewBuilder - private var seriesStatusView: some View { - Picker( - L10n.status, - selection: $item.status - .coalesce("") - .map( - getter: { SeriesStatus(rawValue: $0) ?? .continuing }, - setter: { $0.rawValue } - ) - ) { - ForEach(SeriesStatus.allCases, id: \.self) { status in - Text(status.displayTitle).tag(status) - } - } - } - - // MARK: - Air Time View - - @ViewBuilder - private var airTimeView: some View { - DatePicker( - L10n.airTime, - selection: $item.airTime - .coalesce("00:00") - .map( - getter: { parseAirTimeToDate($0) }, - setter: { formatDateToString($0) } - ), - displayedComponents: .hourAndMinute - ) - } - - // MARK: - Air Days View - - @ViewBuilder - private var airDaysView: some View { - ForEach(DayOfWeek.allCases, id: \.self) { field in - Toggle( - field.displayTitle ?? L10n.unknown, - isOn: $item.airDays - .coalesce([]) - .contains(field) - ) - } - } - - // MARK: - Run Time View - - @ViewBuilder - private var runTimeView: some View { - ChevronButton( - L10n.runtime, - subtitle: ServerTicks(item.runTimeTicks ?? 0) - .seconds.formatted(.hourMinute), - description: L10n.episodeRuntimeDescription - ) { - TextField( - L10n.minutes, - value: $tempRunTime - .coalesce(0) - .min(0), - format: .number - ) - .keyboardType(.numberPad) - } onSave: { - if let tempRunTime, tempRunTime != 0 { - item.runTimeTicks = ServerTicks(minutes: tempRunTime).ticks - } else { - item.runTimeTicks = nil - } - } onCancel: { - if let originalRunTime = item.runTimeTicks { - tempRunTime = Int(ServerTicks(originalRunTime).minutes) - } else { - tempRunTime = nil - } - } - } - - // MARK: - Parse AirTime to Date - - private func parseAirTimeToDate(_ airTime: String) -> Date { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm" - return dateFormatter.date(from: airTime) ?? Date() - } - - // MARK: - Format Date to String - - private func formatDateToString(_ date: Date) -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "HH:mm" - return dateFormatter.string(from: date) - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/TitleSection.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/TitleSection.swift deleted file mode 100644 index 42f2fc77..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/TitleSection.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -extension EditMetadataView { - - struct TitleSection: View { - - @Binding - var item: BaseItemDto - - var body: some View { - Section(L10n.title) { - TextField( - L10n.title, - value: $item.name, - format: .nilIfEmptyString - ) - } - - Section(L10n.originalTitle) { - TextField( - L10n.originalTitle, - value: $item.originalTitle, - format: .nilIfEmptyString - ) - } - - Section(L10n.sortTitle) { - TextField( - L10n.sortTitle, - value: $item.forcedSortName, - format: .nilIfEmptyString - ) - } - } - } -} diff --git a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/EditMetadataView.swift b/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/EditMetadataView.swift deleted file mode 100644 index 15eb9b49..00000000 --- a/jellypig iOS/Views/ItemEditorView/ItemMetadata/EditMetadataView/EditMetadataView.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import JellyfinAPI -import SwiftUI - -struct EditMetadataView: View { - - // MARK: - Observed & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @ObservedObject - private var viewModel: ItemEditorViewModel - - // MARK: - Metadata Variables - - @Binding - var item: BaseItemDto - - @State - private var tempItem: BaseItemDto - - private let itemType: BaseItemKind - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Initializer - - init(viewModel: ItemEditorViewModel) { - self.viewModel = viewModel - self._item = Binding(get: { viewModel.item }, set: { viewModel.item = $0 }) - self._tempItem = State(initialValue: viewModel.item) - self.itemType = viewModel.item.type! - } - - // MARK: - Body - - @ViewBuilder - var body: some View { - ZStack { - switch viewModel.state { - case .initial, .content, .updating: - contentView - case let .error(error): - errorView(with: error) - } - } - .navigationBarTitle(L10n.metadata) - .navigationBarTitleDisplayMode(.inline) - .topBarTrailing { - Button(L10n.save) { - item = tempItem - viewModel.send(.update(tempItem)) - router.dismissCoordinator() - } - .buttonStyle(.toolbarPill) - .disabled(viewModel.item == tempItem) - } - .navigationBarCloseButton { - router.dismissCoordinator() - } - .onReceive(viewModel.events) { events in - switch events { - case let .error(eventError): - error = eventError - default: - break - } - } - .errorMessage($error) - } - - // MARK: - ErrorView - - @ViewBuilder - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.load) - } - } - - // MARK: - Content View - - @ViewBuilder - private var contentView: some View { - Form { - TitleSection(item: $tempItem) - - DateSection( - item: $tempItem, - itemType: itemType - ) - - if itemType == .series { - SeriesSection(item: $tempItem) - } else if itemType == .episode { - EpisodeSection(item: $tempItem) - } - - OverviewSection( - item: $tempItem, - itemType: itemType - ) - - ReviewsSection(item: $tempItem) - - ParentalRatingSection(item: $tempItem) - - if [.movie, .episode].contains(itemType) { - MediaFormatSection(item: $tempItem) - } - - LocalizationSection(item: $tempItem) - - LockMetadataSection(item: $tempItem) - } - } -} diff --git a/jellypig iOS/Views/ItemOverviewView.swift b/jellypig iOS/Views/ItemOverviewView.swift deleted file mode 100644 index aebaaafb..00000000 --- a/jellypig iOS/Views/ItemOverviewView.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct ItemOverviewView: View { - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - let item: BaseItemDto - - var body: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 10) { - - if let firstTagline = item.taglines?.first { - Text(firstTagline) - .font(.title3) - .fontWeight(.semibold) - .multilineTextAlignment(.leading) - } - - if let itemOverview = item.overview { - Text(itemOverview) - .font(.body) - .multilineTextAlignment(.leading) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .edgePadding() - } - .navigationTitle(item.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - } -} diff --git a/jellypig iOS/Views/ItemView/CollectionItemContentView.swift b/jellypig iOS/Views/ItemView/CollectionItemContentView.swift deleted file mode 100644 index a532beef..00000000 --- a/jellypig iOS/Views/ItemView/CollectionItemContentView.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import BlurHashKit -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct CollectionItemContentView: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - var viewModel: CollectionItemViewModel - - var body: some View { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - - // MARK: - Items - - ForEach(viewModel.collectionItems.elements, id: \.key) { element in - if element.value.isNotEmpty { - PosterHStack( - title: element.key.pluralDisplayTitle, - type: .portrait, - items: element.value - ) - .trailing { - SeeAllButton() - .onSelect { - let viewModel = ItemLibraryViewModel( - title: viewModel.item.displayTitle, - id: viewModel.item.id, - element.value - ) - router.route(to: \.library, viewModel) - } - } - .onSelect { item in - router.route(to: \.item, item) - } - } - } - - // MARK: Genres - - if let genres = viewModel.item.itemGenres, genres.isNotEmpty { - ItemView.GenresHStack(genres: genres) - } - - // MARK: Studios - - if let studios = viewModel.item.studios, studios.isNotEmpty { - ItemView.StudiosHStack(studios: studios) - } - - // MARK: Similar - - if viewModel.similarItems.isNotEmpty { - ItemView.SimilarItemsHStack(items: viewModel.similarItems) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/AboutView/AboutView.swift b/jellypig iOS/Views/ItemView/Components/AboutView/AboutView.swift deleted file mode 100644 index 91831d9f..00000000 --- a/jellypig iOS/Views/ItemView/Components/AboutView/AboutView.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionHStack -import Defaults -import IdentifiedCollections -import JellyfinAPI -import SwiftUI - -// TODO: rename `AboutItemView` -// TODO: see what to do about bottom padding -// - don't like it adds more than the edge -// - just have this determine bottom padding -// instead of scrollviews? - -extension ItemView { - - struct AboutView: View { - - private enum AboutViewItem: Identifiable { - case image - case overview - case mediaSource(MediaSourceInfo) - case ratings - - var id: String? { - switch self { - case .image: - return "image" - case .overview: - return "overview" - case let .mediaSource(source): - return source.id - case .ratings: - return "ratings" - } - } - } - - @ObservedObject - var viewModel: ItemViewModel - - @State - private var contentSize: CGSize = .zero - - private var items: [AboutViewItem] { - var items: [AboutViewItem] = [ - .image, - .overview, - ] - - if let mediaSources = viewModel.item.mediaSources { - items.append(contentsOf: mediaSources.map { AboutViewItem.mediaSource($0) }) - } - - if viewModel.item.hasRatings { - items.append(.ratings) - } - - return items - } - - init(viewModel: ItemViewModel) { - self.viewModel = viewModel - } - - // TODO: break out into a general solution for general use? - // use similar math from CollectionHStack - private var padImageWidth: CGFloat { - let portraitMinWidth: CGFloat = 140 - let contentWidth = contentSize.width - let usableWidth = contentWidth - EdgeInsets.edgePadding * 2 - var columns = CGFloat(Int(usableWidth / portraitMinWidth)) - let preItemSpacing = (columns - 1) * (EdgeInsets.edgePadding / 2) - let preTotalNegative = EdgeInsets.edgePadding * 2 + preItemSpacing - - if columns * portraitMinWidth + preTotalNegative > contentWidth { - columns -= 1 - } - - let itemSpacing = (columns - 1) * (EdgeInsets.edgePadding / 2) - let totalNegative = EdgeInsets.edgePadding * 2 + itemSpacing - let itemWidth = (contentWidth - totalNegative) / columns - - return max(0, itemWidth) - } - - private var phoneImageWidth: CGFloat { - let contentWidth = contentSize.width - let usableWidth = contentWidth - EdgeInsets.edgePadding * 2 - let itemSpacing = (EdgeInsets.edgePadding / 2) * 2 - let itemWidth = (usableWidth - itemSpacing) / 3 - - return max(0, itemWidth) - } - - private var cardSize: CGSize { - let height = UIDevice.isPad ? padImageWidth * 3 / 2 : phoneImageWidth * 3 / 2 - let width = height * 1.65 - - return CGSize(width: width, height: height) - } - - var body: some View { - VStack(alignment: .leading) { - L10n.about.text - .font(.title2) - .fontWeight(.bold) - .accessibility(addTraits: [.isHeader]) - .edgePadding(.horizontal) - - CollectionHStack( - uniqueElements: items, - variadicWidths: true - ) { item in - switch item { - case .image: - ImageCard(viewModel: viewModel) - .frame(width: UIDevice.isPad ? padImageWidth : phoneImageWidth) - case .overview: - OverviewCard(item: viewModel.item) - .frame(width: cardSize.width, height: cardSize.height) - case let .mediaSource(source): - MediaSourcesCard( - subtitle: (viewModel.item.mediaSources ?? []).count > 1 ? source.displayTitle : nil, - source: source - ) - .frame(width: cardSize.width, height: cardSize.height) - case .ratings: - RatingsCard(item: viewModel.item) - .frame(width: cardSize.width, height: cardSize.height) - } - } - .clipsToBounds(false) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollBehavior(.continuousLeadingEdge) - } - .trackingSize($contentSize) - .id(viewModel.item.hashValue) - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/AboutView/Components/AboutView+Card.swift b/jellypig iOS/Views/ItemView/Components/AboutView/Components/AboutView+Card.swift deleted file mode 100644 index 710d80df..00000000 --- a/jellypig iOS/Views/ItemView/Components/AboutView/Components/AboutView+Card.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension ItemView.AboutView { - - struct Card: View { - - private var content: () -> any View - private var onSelect: () -> Void - private let title: String - private let subtitle: String? - - var body: some View { - Button { - onSelect() - } label: { - ZStack(alignment: .leading) { - - Color.systemFill - .cornerRadius(ratio: 1 / 45, of: \.height) - - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - - if let subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer() - - content() - .eraseToAnyView() - } - .padding() - } - } - .buttonStyle(.plain) - } - } -} - -extension ItemView.AboutView.Card { - - init(title: String, subtitle: String? = nil) { - self.init( - content: { EmptyView() }, - onSelect: {}, - title: title, - subtitle: subtitle - ) - } - - func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.content, with: content) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift b/jellypig iOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift deleted file mode 100644 index f5a99283..00000000 --- a/jellypig iOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct ImageCard: View { - - // MARK: - Environment & Observed Objects - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - var viewModel: ItemViewModel - - // MARK: - Body - - var body: some View { - PosterButton(item: viewModel.item, type: .portrait) - .content { EmptyView() } - .imageOverlay { EmptyView() } - .onSelect(onSelect) - } - - // MARK: - On Select - - // Switch case to allow other funcitonality if we need to expand this beyond episode > series - private func onSelect() { - switch viewModel.item.type { - case .episode: - if let episodeViewModel = viewModel as? EpisodeItemViewModel, - let seriesItem = episodeViewModel.seriesItem - { - router.route(to: \.item, seriesItem) - } - default: - break - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift b/jellypig iOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift deleted file mode 100644 index 27ddd554..00000000 --- a/jellypig iOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct MediaSourcesCard: View { - - @Default(.accentColor) - private var accentColor - - @EnvironmentObject - private var router: ItemCoordinator.Router - - let subtitle: String? - let source: MediaSourceInfo - - var body: some View { - Card(title: L10n.media, subtitle: subtitle) - .content { - if let mediaStreams = source.mediaStreams { - VStack(alignment: .leading) { - Text(mediaStreams.compactMap(\.displayTitle).prefix(4).joined(separator: "\n")) - .font(.footnote) - - if mediaStreams.count > 4 { - L10n.seeMore.text - .font(.footnote) - .foregroundColor(accentColor) - } - } - } - } - .onSelect { - router.route(to: \.mediaSourceInfo, source) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift b/jellypig iOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift deleted file mode 100644 index d30c1cc3..00000000 --- a/jellypig iOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct OverviewCard: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - let item: BaseItemDto - - var body: some View { - Card(title: item.displayTitle, subtitle: item.alternateTitle) - .content { - if let overview = item.overview { - TruncatedText(overview) - .lineLimit(4) - .font(.footnote) - .allowsHitTesting(false) - } else { - L10n.noOverviewAvailable.text - .font(.footnote) - .foregroundColor(.secondary) - } - } - .onSelect { - router.route(to: \.itemOverview, item) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift b/jellypig iOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift deleted file mode 100644 index 35935d93..00000000 --- a/jellypig iOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct RatingsCard: View { - - let item: BaseItemDto - - var body: some View { - Card(title: L10n.ratings) - .content { - HStack(alignment: .bottom, spacing: 20) { - if let criticRating = item.criticRating { - VStack { - Group { - if criticRating >= 60 { - Image(.tomatoFresh) - .symbolRenderingMode(.multicolor) - .foregroundStyle(.green, .red) - } else { - Image(.tomatoRotten) - .symbolRenderingMode(.monochrome) - .foregroundColor(.green) - } - } - .font(.largeTitle) - - Text("\(criticRating, specifier: "%.0f")") - } - } - - if let communityRating = item.communityRating { - VStack { - Image(systemName: "star.fill") - .symbolRenderingMode(.multicolor) - .foregroundStyle(.yellow) - .font(.largeTitle) - - Text("\(communityRating, specifier: "%.1f")") - } - } - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/ActionButton/ActionButton.swift b/jellypig iOS/Views/ItemView/Components/ActionButton/ActionButton.swift deleted file mode 100644 index 2aabe863..00000000 --- a/jellypig iOS/Views/ItemView/Components/ActionButton/ActionButton.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension ItemView { - - struct ActionButton: View { - - @Environment(\.isSelected) - private var isSelected - - private let content: () -> Content - private let icon: String - private let onSelect: () -> Void - private let selectedIcon: String? - private let title: String - - private var labelIconName: String { - isSelected ? selectedIcon ?? icon : icon - } - - // MARK: - Body - - var body: some View { - Group { - if Content.self == EmptyView.self { - Button( - title, - systemImage: labelIconName, - action: onSelect - ) - .buttonStyle(.plain) - } else { - Menu( - title, - systemImage: labelIconName, - content: content - ) - } - } - .symbolRenderingMode(.palette) - .labelStyle(.iconOnly) - .animation(.easeInOut(duration: 0.1), value: isSelected) - } - } -} - -// MARK: - Initializers - -extension ItemView.ActionButton { - - // MARK: Button Initializer - - init( - _ title: String, - icon: String, - selectedIcon: String? = nil, - onSelect: @escaping () -> Void - ) where Content == EmptyView { - self.title = title - self.icon = icon - self.selectedIcon = selectedIcon - self.onSelect = onSelect - self.content = { EmptyView() } - } - - // MARK: Menu Initializer - - init( - _ title: String, - icon: String, - @ViewBuilder content: @escaping () -> Content - ) { - self.title = title - self.icon = icon - self.selectedIcon = icon - self.onSelect = {} - self.content = content - } -} diff --git a/jellypig iOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/jellypig iOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift deleted file mode 100644 index 9663d3df..00000000 --- a/jellypig iOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import JellyfinAPI -import SwiftUI - -// TODO: replace `equalSpacing` handling with a `Layout` - -extension ItemView { - - struct ActionButtonHStack: View { - - @Default(.accentColor) - private var accentColor - - @StoredValue(.User.enabledTrailers) - private var enabledTrailers: TrailerSelection - - @ObservedObject - private var viewModel: ItemViewModel - - private let equalSpacing: Bool - - // MARK: - Has Trailers - - private var hasTrailers: Bool { - if enabledTrailers.contains(.local), viewModel.localTrailers.isNotEmpty { - return true - } - - if enabledTrailers.contains(.external), viewModel.item.remoteTrailers?.isNotEmpty == true { - return true - } - - return false - } - - // MARK: - Initializer - - init(viewModel: ItemViewModel, equalSpacing: Bool = true) { - self.viewModel = viewModel - self.equalSpacing = equalSpacing - } - - // MARK: - Body - - var body: some View { - HStack(alignment: .center, spacing: 15) { - - // MARK: Toggle Played - - let isCheckmarkSelected = viewModel.item.userData?.isPlayed == true - - ActionButton( - L10n.played, - icon: "checkmark.circle", - selectedIcon: "checkmark.circle.fill" - ) { - UIDevice.impact(.light) - viewModel.send(.toggleIsPlayed) - } - .environment(\.isSelected, isCheckmarkSelected) - .if(isCheckmarkSelected) { item in - item - .foregroundStyle( - .primary, - accentColor - ) - } - .if(equalSpacing) { view in - view.frame(maxWidth: .infinity) - } - - // MARK: Toggle Favorite - - let isHeartSelected = viewModel.item.userData?.isFavorite == true - - ActionButton( - L10n.favorited, - icon: "heart", - selectedIcon: "heart.fill" - ) { - UIDevice.impact(.light) - viewModel.send(.toggleIsFavorite) - } - .environment(\.isSelected, isHeartSelected) - .if(isHeartSelected) { item in - item - .foregroundStyle(Color.red) - } - .if(equalSpacing) { view in - view.frame(maxWidth: .infinity) - } - - // MARK: Select a Version - - if let mediaSources = viewModel.playButtonItem?.mediaSources, - mediaSources.count > 1 - { - VersionMenu(viewModel: viewModel, mediaSources: mediaSources) - .if(equalSpacing) { view in - view.frame(maxWidth: .infinity) - } - } - - // MARK: Watch a Trailer - - if hasTrailers { - TrailerMenu( - localTrailers: viewModel.localTrailers, - externalTrailers: viewModel.item.remoteTrailers ?? [] - ) - .if(equalSpacing) { view in - view.frame(maxWidth: .infinity) - } - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift b/jellypig iOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift deleted file mode 100644 index 386ea8bd..00000000 --- a/jellypig iOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Factory -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct TrailerMenu: View { - - @Injected(\.logService) - private var logger - - // MARK: - Stored Value - - @StoredValue(.User.enabledTrailers) - private var enabledTrailers: TrailerSelection - - // MARK: - Observed & Envirnoment Objects - - @EnvironmentObject - private var router: MainCoordinator.Router - - // MARK: - Error State - - @State - private var error: Error? - - let localTrailers: [BaseItemDto] - let externalTrailers: [MediaURL] - - private var showLocalTrailers: Bool { - enabledTrailers.contains(.local) && localTrailers.isNotEmpty - } - - private var showExternalTrailers: Bool { - enabledTrailers.contains(.external) && externalTrailers.isNotEmpty - } - - // MARK: - Body - - var body: some View { - Group { - switch localTrailers.count + externalTrailers.count { - case 1: - trailerButton - default: - trailerMenu - } - } - .errorMessage($error) - } - - // MARK: - Single Trailer Button - - @ViewBuilder - private var trailerButton: some View { - ActionButton( - L10n.trailers, - icon: "movieclapper" - ) { - if showLocalTrailers, let firstTrailer = localTrailers.first { - playLocalTrailer(firstTrailer) - } - - if showExternalTrailers, let firstTrailer = externalTrailers.first { - playExternalTrailer(firstTrailer) - } - } - } - - // MARK: - Multiple Trailers Menu Button - - @ViewBuilder - private var trailerMenu: some View { - ActionButton(L10n.trailers, icon: "movieclapper") { - - if showLocalTrailers { - Section(L10n.local) { - ForEach(localTrailers) { trailer in - Button( - trailer.name ?? L10n.trailer, - systemImage: "play.fill" - ) { - playLocalTrailer(trailer) - } - } - } - } - - if showExternalTrailers { - Section(L10n.external) { - ForEach(externalTrailers, id: \.self) { mediaURL in - Button( - mediaURL.name ?? L10n.trailer, - systemImage: "arrow.up.forward" - ) { - playExternalTrailer(mediaURL) - } - } - } - } - } - } - - // MARK: - Play: Local Trailer - - private func playLocalTrailer(_ trailer: BaseItemDto) { - if let selectedMediaSource = trailer.mediaSources?.first { - router.route( - to: \.videoPlayer, - OnlineVideoPlayerManager(item: trailer, mediaSource: selectedMediaSource) - ) - } else { - logger.log(level: .error, "No media sources found") - error = JellyfinAPIError(L10n.unknownError) - } - } - - // MARK: - Play: External Trailer - - private func playExternalTrailer(_ trailer: MediaURL) { - if let url = URL(string: trailer.url), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) { success in - guard !success else { return } - - error = JellyfinAPIError(L10n.unableToOpenTrailer) - } - } else { - error = JellyfinAPIError(L10n.unableToOpenTrailer) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift b/jellypig iOS/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift deleted file mode 100644 index 75438ba7..00000000 --- a/jellypig iOS/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct VersionMenu: View { - - @ObservedObject - var viewModel: ItemViewModel - - let mediaSources: [MediaSourceInfo] - - // MARK: - Selected Media Source Binding - - private var selectedMediaSource: Binding { - Binding( - get: { viewModel.selectedMediaSource }, - set: { newSource in - if let newSource = newSource { - viewModel.send(.selectMediaSource(newSource)) - } - } - ) - } - - // MARK: - Body - - var body: some View { - ActionButton(L10n.version, icon: "list.dash") { - Picker(L10n.version, selection: selectedMediaSource) { - ForEach(mediaSources, id: \.hashValue) { mediaSource in - Button { - Text(mediaSource.displayTitle) - } - .tag(mediaSource as MediaSourceInfo?) - } - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/AttributeHStack.swift b/jellypig iOS/Views/ItemView/Components/AttributeHStack.swift deleted file mode 100644 index 6e3c483a..00000000 --- a/jellypig iOS/Views/ItemView/Components/AttributeHStack.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI -import WrappingHStack - -extension ItemView { - - struct AttributesHStack: View { - - @StoredValue(.User.itemViewAttributes) - private var itemViewAttributes - - @ObservedObject - private var viewModel: ItemViewModel - - private let alignment: HorizontalAlignment - - init( - viewModel: ItemViewModel, - alignment: HorizontalAlignment = .center - ) { - self.viewModel = viewModel - self.alignment = alignment - } - - var body: some View { - let badges = computeBadges() - - if badges.isNotEmpty { - WrappingHStack( - badges, - id: \.self, - alignment: alignment, - spacing: .constant(8), - lineSpacing: 8 - ) { badgeItem in - badgeItem - .fixedSize(horizontal: true, vertical: false) - } - .foregroundStyle(Color(UIColor.darkGray)) - .lineLimit(1) - } - } - - // MARK: - Compute Badges - - private func computeBadges() -> [AttributeBadge] { - var badges: [AttributeBadge] = [] - - for attribute in itemViewAttributes { - - var badge: AttributeBadge? = nil - - switch attribute { - case .ratingCritics: - if let criticRating = viewModel.item.criticRating { - badge = AttributeBadge( - style: .outline, - title: Text("\(criticRating, specifier: "%.0f")") - ) { - if criticRating >= 60 { - Image(.tomatoFresh) - .symbolRenderingMode(.hierarchical) - } else { - Image(.tomatoRotten) - } - } - } - case .ratingCommunity: - if let communityRating = viewModel.item.communityRating { - badge = AttributeBadge( - style: .outline, - title: Text("\(communityRating, specifier: "%.01f")"), - systemName: "star.fill" - ) - } - case .ratingOfficial: - if let officialRating = viewModel.item.officialRating { - badge = AttributeBadge( - style: .outline, - title: officialRating - ) - } - case .videoQuality: - if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { - // Resolution badge (if available). Only one of 4K or HD is shown. - if mediaStreams.has4KVideo { - badge = AttributeBadge( - style: .fill, - title: "4K" - ) - } else if mediaStreams.hasHDVideo { - badge = AttributeBadge( - style: .fill, - title: "HD" - ) - } - if mediaStreams.hasDolbyVision { - badge = AttributeBadge( - style: .fill, - title: "DV" - ) - } - if mediaStreams.hasHDRVideo { - badge = AttributeBadge( - style: .fill, - title: "HDR" - ) - } - } - case .audioChannels: - if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { - if mediaStreams.has51AudioChannelLayout { - badge = AttributeBadge( - style: .fill, - title: "5.1" - ) - } - if mediaStreams.has71AudioChannelLayout { - badge = AttributeBadge( - style: .fill, - title: "7.1" - ) - } - } - case .subtitles: - if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams, - mediaStreams.hasSubtitles - { - badge = AttributeBadge( - style: .outline, - title: "CC" - ) - } - } - - if let badge { - badges.append(badge) - } - } - - return badges - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/CastAndCrewHStack.swift b/jellypig iOS/Views/ItemView/Components/CastAndCrewHStack.swift deleted file mode 100644 index ac225368..00000000 --- a/jellypig iOS/Views/ItemView/Components/CastAndCrewHStack.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct CastAndCrewHStack: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - let people: [BaseItemPerson] - - var body: some View { - PosterHStack( - title: L10n.castAndCrew, - type: .portrait, - items: people.filter { person in - person.type?.isSupported ?? false - } - ) - .trailing { - SeeAllButton() - .onSelect { - router.route(to: \.castAndCrew, people) - } - } - .onSelect { person in - let viewModel = ItemLibraryViewModel(parent: person) - router.route(to: \.library, viewModel) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/DownloadTaskButton.swift b/jellypig iOS/Views/ItemView/Components/DownloadTaskButton.swift deleted file mode 100644 index 5c0d37e4..00000000 --- a/jellypig iOS/Views/ItemView/Components/DownloadTaskButton.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Factory -import JellyfinAPI -import SwiftUI - -struct DownloadTaskButton: View { - - @ObservedObject - private var downloadManager: DownloadManager - @ObservedObject - private var downloadTask: DownloadTask - - private var onSelect: (DownloadTask) -> Void - - var body: some View { - Button { - onSelect(downloadTask) - } label: { - switch downloadTask.state { - case .cancelled: - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - case .complete: - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - case .downloading: - EmptyView() -// CircularProgressView(progress: progress) - case .error: - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - case .ready: - Image(systemName: "arrow.down.circle") - } - } - } -} - -extension DownloadTaskButton { - - init(item: BaseItemDto) { - let downloadManager = Container.shared.downloadManager() - - self.downloadTask = downloadManager.task(for: item) ?? .init(item: item) - self.onSelect = { _ in } - self.downloadManager = downloadManager - } - - func onSelect(_ action: @escaping (DownloadTask) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift b/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift deleted file mode 100644 index ddd8c62d..00000000 --- a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI -import SwiftUI - -extension SeriesEpisodeSelector { - - struct EmptyCard: View { - - var body: some View { - VStack(alignment: .leading) { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) - - SeriesEpisodeSelector.EpisodeContent( - subHeader: .emptyDash, - header: L10n.noResults, - content: L10n.noEpisodesAvailable - ) - .disabled(true) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift deleted file mode 100644 index fa60cc94..00000000 --- a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI -import SwiftUI - -extension SeriesEpisodeSelector { - - struct EpisodeCard: View { - - @EnvironmentObject - private var mainRouter: MainCoordinator.Router - @EnvironmentObject - private var router: ItemCoordinator.Router - - let episode: BaseItemDto - - @ViewBuilder - private var overlayView: some View { - if let progressLabel = episode.progressLabel { - LandscapePosterProgressBar( - title: progressLabel, - progress: (episode.userData?.playedPercentage ?? 0) / 100 - ) - } else if episode.userData?.isPlayed ?? false { - ZStack(alignment: .bottomTrailing) { - Color.clear - - Image(systemName: "checkmark.circle.fill") - .resizable() - .frame(width: 30, height: 30, alignment: .bottomTrailing) - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .black) - .padding() - } - } - } - - private var episodeContent: String { - if episode.isUnaired { - episode.airDateLabel ?? L10n.noOverviewAvailable - } else { - episode.overview ?? L10n.noOverviewAvailable - } - } - - var body: some View { - VStack(alignment: .leading) { - Button { - guard let mediaSource = episode.mediaSources?.first else { return } - mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: episode, mediaSource: mediaSource)) - } label: { - ZStack { - Color.clear - - ImageView(episode.imageSource(.primary, maxWidth: 250)) - .failure { - SystemImageContentView(systemName: episode.systemImage) - } - - overlayView - } - .posterStyle(.landscape) - } - - SeriesEpisodeSelector.EpisodeContent( - subHeader: episode.episodeLocator ?? .emptyDash, - header: episode.displayTitle, - content: episodeContent - ) - .onSelect { - router.route(to: \.item, episode) - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift b/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift deleted file mode 100644 index ce6de9e4..00000000 --- a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension SeriesEpisodeSelector { - - struct EpisodeContent: View { - - @Default(.accentColor) - private var accentColor - - private var onSelect: () -> Void - - let subHeader: String - let header: String - let content: String - - @ViewBuilder - private var subHeaderView: some View { - Text(subHeader) - .font(.footnote) - .foregroundColor(.secondary) - .lineLimit(1) - } - - @ViewBuilder - private var headerView: some View { - Text(header) - .font(.body) - .foregroundColor(.primary) - .lineLimit(1) - .multilineTextAlignment(.leading) - .padding(.bottom, 1) - } - - @ViewBuilder - private var contentView: some View { - Text(content) - .font(.caption.weight(.light)) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - .backport - .lineLimit(3, reservesSpace: true) - .font(.caption.weight(.light)) - } - - var body: some View { - Button { - onSelect() - } label: { - VStack(alignment: .leading) { - subHeaderView - - headerView - - contentView - .iOS15 { v in - v.frame( - height: "A\nA\nA".height( - withConstrainedWidth: 10, - font: Font.caption.uiFont ?? UIFont.preferredFont(forTextStyle: .body) - ) - ) - } - - L10n.seeMore.text - .font(.caption.weight(.light)) - .foregroundStyle(accentColor) - } - } - } - } -} - -extension SeriesEpisodeSelector.EpisodeContent { - - init( - subHeader: String, - header: String, - content: String - ) { - self.subHeader = subHeader - self.header = header - self.content = content - self.onSelect = {} - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift deleted file mode 100644 index ec230a3a..00000000 --- a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionHStack -import Foundation -import JellyfinAPI -import SwiftUI - -// TODO: The content/loading/error states are implemented as different CollectionHStacks because it was just easy. -// A theoretically better implementation would be a single CollectionHStack with cards that represent the state instead. -extension SeriesEpisodeSelector { - - struct EpisodeHStack: View { - - @ObservedObject - var viewModel: SeasonItemViewModel - - @State - private var didScrollToPlayButtonItem = false - - @StateObject - private var proxy = CollectionHStackProxy() - - let playButtonItem: BaseItemDto? - - private func contentView(viewModel: SeasonItemViewModel) -> some View { - CollectionHStack( - uniqueElements: viewModel.elements, - id: \.unwrappedIDHashOrZero, - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { episode in - SeriesEpisodeSelector.EpisodeCard(episode: episode) - } - .scrollBehavior(.continuousLeadingEdge) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .proxy(proxy) - .onFirstAppear { - guard !didScrollToPlayButtonItem else { return } - didScrollToPlayButtonItem = true - - // good enough? - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let playButtonItem else { return } - proxy.scrollTo(id: playButtonItem.unwrappedIDHashOrZero, animated: false) - } - } - } - - var body: some View { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - EmptyHStack() - } else { - contentView(viewModel: viewModel) - } - case let .error(error): - ErrorHStack(viewModel: viewModel, error: error) - case .initial, .refreshing: - LoadingHStack() - } - } - } - - struct EmptyHStack: View { - - var body: some View { - CollectionHStack( - count: 1, - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { _ in - SeriesEpisodeSelector.EmptyCard() - } - .allowScrolling(false) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - } - } - - // TODO: better refresh design - struct ErrorHStack: View { - - @ObservedObject - var viewModel: SeasonItemViewModel - - let error: JellyfinAPIError - - var body: some View { - CollectionHStack( - count: 1, - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { _ in - SeriesEpisodeSelector.ErrorCard(error: error) - .onSelect { - viewModel.send(.refresh) - } - } - .allowScrolling(false) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - } - } - - struct LoadingHStack: View { - - var body: some View { - CollectionHStack( - count: Int.random(in: 2 ..< 5), - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { _ in - SeriesEpisodeSelector.LoadingCard() - } - .allowScrolling(false) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift b/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift deleted file mode 100644 index 30b57b91..00000000 --- a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension SeriesEpisodeSelector { - - struct ErrorCard: View { - - let error: JellyfinAPIError - private var onSelect: () -> Void - - init(error: JellyfinAPIError) { - self.error = error - self.onSelect = {} - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } - - var body: some View { - Button { - onSelect() - } label: { - VStack(alignment: .leading) { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) - .overlay { - Image(systemName: "arrow.clockwise.circle.fill") - .font(.system(size: 40)) - } - - SeriesEpisodeSelector.EpisodeContent( - subHeader: .emptyDash, - header: L10n.error, - content: error.localizedDescription - ) - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift b/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift deleted file mode 100644 index 6a907269..00000000 --- a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Foundation -import JellyfinAPI -import SwiftUI - -extension SeriesEpisodeSelector { - - struct LoadingCard: View { - - var body: some View { - VStack(alignment: .leading) { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) - - SeriesEpisodeSelector.EpisodeContent( - subHeader: String.random(count: 7 ..< 12), - header: String.random(count: 10 ..< 20), - content: String.random(count: 20 ..< 80) - ) - .redacted(reason: .placeholder) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/jellypig iOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift deleted file mode 100644 index 73e6b00d..00000000 --- a/jellypig iOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionHStack -import Defaults -import JellyfinAPI -import OrderedCollections -import SwiftUI - -struct SeriesEpisodeSelector: View { - - @ObservedObject - var viewModel: SeriesItemViewModel - - @State - private var didSelectPlayButtonSeason = false - @State - private var selection: SeasonItemViewModel.ID? - - private var selectionViewModel: SeasonItemViewModel? { - viewModel.seasons.first(where: { $0.id == selection }) - } - - @ViewBuilder - private var seasonSelectorMenu: some View { - Menu { - ForEach(viewModel.seasons, id: \.season.id) { seasonViewModel in - Button { - selection = seasonViewModel.id - } label: { - if seasonViewModel.id == selection { - Label(seasonViewModel.season.displayTitle, systemImage: "checkmark") - } else { - Text(seasonViewModel.season.displayTitle) - } - } - } - } label: { - Label( - selectionViewModel?.season.displayTitle ?? .emptyDash, - systemImage: "chevron.down" - ) - .labelStyle(.episodeSelector) - } - } - - var body: some View { - VStack(alignment: .leading) { - - seasonSelectorMenu - .edgePadding([.bottom, .horizontal]) - - Group { - if let selectionViewModel { - EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem) - } else { - LoadingHStack() - } - } - .transition(.opacity.animation(.linear(duration: 0.1))) - } - .onReceive(viewModel.playButtonItem.publisher) { newValue in - - guard !didSelectPlayButtonSeason else { return } - didSelectPlayButtonSeason = true - - if let playButtonSeason = viewModel.seasons.first(where: { $0.id == newValue.seasonID }) { - selection = playButtonSeason.id - } else { - selection = viewModel.seasons.first?.id - } - } - .onChange(of: selection) { _ in - guard let selectionViewModel else { return } - - if selectionViewModel.state == .initial { - selectionViewModel.send(.refresh) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/GenresHStack.swift b/jellypig iOS/Views/ItemView/Components/GenresHStack.swift deleted file mode 100644 index 842d222b..00000000 --- a/jellypig iOS/Views/ItemView/Components/GenresHStack.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct GenresHStack: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - let genres: [ItemGenre] - - var body: some View { - PillHStack( - title: L10n.genres, - items: genres - ).onSelect { genre in - let viewModel = ItemLibraryViewModel( - title: genre.displayTitle, - id: genre.value, - filters: .init(genres: [genre]) - ) - router.route(to: \.library, viewModel) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/OffsetScrollView.swift b/jellypig iOS/Views/ItemView/Components/OffsetScrollView.swift deleted file mode 100644 index 1855d833..00000000 --- a/jellypig iOS/Views/ItemView/Components/OffsetScrollView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: given height or height ratio options - -// The fading values just "feel right" and is the same for iOS and iPadOS. -// Adjust if necessary or if a more concrete design comes along. - -extension ItemView { - - struct OffsetScrollView: View { - - @State - private var scrollViewOffset: CGFloat = 0 - @State - private var size: CGSize = .zero - @State - private var safeAreaInsets: EdgeInsets = .zero - - private let header: () -> Header - private let overlay: () -> Overlay - private let content: () -> Content - private let heightRatio: CGFloat - - init( - headerHeight: CGFloat = 0, - @ViewBuilder header: @escaping () -> Header, - @ViewBuilder overlay: @escaping () -> Overlay, - @ViewBuilder content: @escaping () -> Content - ) { - self.header = header - self.overlay = overlay - self.content = content - self.heightRatio = headerHeight - } - - private var headerOpacity: CGFloat { - let start = (size.height + safeAreaInsets.vertical) * heightRatio - safeAreaInsets.top - 90 - let end = (size.height + safeAreaInsets.vertical) * heightRatio - safeAreaInsets.top - 40 - let diff = end - start - let opacity = clamp((scrollViewOffset - start) / diff, min: 0, max: 1) - return opacity - } - - var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - overlay() - .frame(height: (size.height + safeAreaInsets.vertical) * heightRatio) - .overlay { - Color.systemBackground - .opacity(headerOpacity) - } - - content() - } - } - .edgesIgnoringSafeArea(.top) - .onSizeChanged { size, safeAreaInsets in - self.size = size - self.safeAreaInsets = safeAreaInsets - } - .scrollViewOffset($scrollViewOffset) - .navigationBarOffset( - $scrollViewOffset, - start: (size.height + safeAreaInsets.vertical) * heightRatio - safeAreaInsets.top - 45, - end: (size.height + safeAreaInsets.vertical) * heightRatio - safeAreaInsets.top - 5 - ) - .backgroundParallaxHeader( - $scrollViewOffset, - height: (size.height + safeAreaInsets.vertical) * heightRatio, - multiplier: 0.3 - ) { - header() - .frame(height: (size.height + safeAreaInsets.vertical) * heightRatio) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/OverviewView.swift b/jellypig iOS/Views/ItemView/Components/OverviewView.swift deleted file mode 100644 index c8d6af63..00000000 --- a/jellypig iOS/Views/ItemView/Components/OverviewView.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct OverviewView: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - let item: BaseItemDto - private var overviewLineLimit: Int? - private var taglineLineLimit: Int? - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - - if let firstTagline = item.taglines?.first { - Text(firstTagline) - .font(.body) - .fontWeight(.semibold) - .multilineTextAlignment(.leading) - .lineLimit(taglineLineLimit) - } - - if let itemOverview = item.overview { - TruncatedText(itemOverview) - .onSeeMore { - router.route(to: \.itemOverview, item) - } - .seeMoreType(.view) - .font(.footnote) - .lineLimit(overviewLineLimit) - } - } - } - } -} - -extension ItemView.OverviewView { - - init(item: BaseItemDto) { - self.init( - item: item, - overviewLineLimit: nil, - taglineLineLimit: nil - ) - } - - func overviewLineLimit(_ limit: Int) -> Self { - copy(modifying: \.overviewLineLimit, with: limit) - } - - func taglineLineLimit(_ limit: Int) -> Self { - copy(modifying: \.taglineLineLimit, with: limit) - } -} diff --git a/jellypig iOS/Views/ItemView/Components/PlayButton.swift b/jellypig iOS/Views/ItemView/Components/PlayButton.swift deleted file mode 100644 index d23079c1..00000000 --- a/jellypig iOS/Views/ItemView/Components/PlayButton.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import SwiftUI - -// TODO: fix play from beginning - -extension ItemView { - - struct PlayButton: View { - - @Default(.accentColor) - private var accentColor - - @Injected(\.logService) - private var logger - - @EnvironmentObject - private var mainRouter: MainCoordinator.Router - - @ObservedObject - var viewModel: ItemViewModel - - private var title: String { - if let seriesViewModel = viewModel as? SeriesItemViewModel { - return seriesViewModel.playButtonItem?.seasonEpisodeLabel ?? L10n.play - } else { - return viewModel.playButtonItem?.playButtonLabel ?? L10n.play - } - } - - var body: some View { - Button { - if let playButtonItem = viewModel.playButtonItem, let selectedMediaSource = viewModel.selectedMediaSource { - mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: playButtonItem, mediaSource: selectedMediaSource)) - } else { - logger.error("No media source available") - } - } label: { - ZStack { - Rectangle() - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : accentColor) - .cornerRadius(10) - - HStack { - Image(systemName: "play.fill") - .font(.system(size: 20)) - - Text(title) - .font(.callout) - .fontWeight(.semibold) - } - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : accentColor.overlayColor) - } - } - .disabled(viewModel.playButtonItem == nil) -// .contextMenu { -// if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { -// Button { -// if let selectedVideoPlayerViewModel = viewModel.legacyselectedVideoPlayerViewModel { -// selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) -// router.route(to: \.legacyVideoPlayer, selectedVideoPlayerViewModel) -// } else { -// logger.error("Attempted to play item but no playback information available") -// } -// } label: { -// Label(L10n.playFromBeginning, systemImage: "gobackward") -// } -// } -// } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/SimilarItemsHStack.swift b/jellypig iOS/Views/ItemView/Components/SimilarItemsHStack.swift deleted file mode 100644 index ec180367..00000000 --- a/jellypig iOS/Views/ItemView/Components/SimilarItemsHStack.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import OrderedCollections -import SwiftUI - -extension ItemView { - - struct SimilarItemsHStack: View { - - @Default(.Customization.similarPosterType) - private var similarPosterType - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @StateObject - private var viewModel: PagingLibraryViewModel - - init(items: [BaseItemDto]) { - self._viewModel = StateObject(wrappedValue: PagingLibraryViewModel(items, parent: BaseItemDto(name: L10n.recommended))) - } - - var body: some View { - PosterHStack( - title: L10n.recommended, - type: similarPosterType, - items: viewModel.elements - ) - .trailing { - SeeAllButton() - .onSelect { - router.route(to: \.library, viewModel) - } - } - .onSelect { item in - router.route(to: \.item, item) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/SpecialFeatureHStack.swift b/jellypig iOS/Views/ItemView/Components/SpecialFeatureHStack.swift deleted file mode 100644 index 7297f391..00000000 --- a/jellypig iOS/Views/ItemView/Components/SpecialFeatureHStack.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import OrderedCollections -import SwiftUI - -extension ItemView { - - struct SpecialFeaturesHStack: View { - - @EnvironmentObject - private var router: MainCoordinator.Router - - let items: [BaseItemDto] - - var body: some View { - PosterHStack( - title: L10n.specialFeatures, - type: .landscape, - items: items - ) - .onSelect { item in - guard let mediaSource = item.mediaSources?.first else { return } - router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: item, mediaSource: mediaSource)) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/Components/StudiosHStack.swift b/jellypig iOS/Views/ItemView/Components/StudiosHStack.swift deleted file mode 100644 index 21794aa7..00000000 --- a/jellypig iOS/Views/ItemView/Components/StudiosHStack.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct StudiosHStack: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - let studios: [NameGuidPair] - - var body: some View { - PillHStack( - title: L10n.studios, - items: studios - ).onSelect { studio in - let viewModel = ItemLibraryViewModel(parent: studio) - router.route(to: \.library, viewModel) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/EpisodeItemContentView.swift b/jellypig iOS/Views/ItemView/EpisodeItemContentView.swift deleted file mode 100644 index 1a9186d8..00000000 --- a/jellypig iOS/Views/ItemView/EpisodeItemContentView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import BlurHashKit -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct EpisodeItemContentView: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - var viewModel: EpisodeItemViewModel - - var body: some View { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - - // MARK: Genres - - if let genres = viewModel.item.itemGenres, genres.isNotEmpty { - ItemView.GenresHStack(genres: genres) - } - - // MARK: Studios - - if let studios = viewModel.item.studios, studios.isNotEmpty { - ItemView.StudiosHStack(studios: studios) - } - - // MARK: Cast and Crew - - if let castAndCrew = viewModel.item.people, - castAndCrew.isNotEmpty - { - ItemView.CastAndCrewHStack(people: castAndCrew) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/ItemView.swift b/jellypig iOS/Views/ItemView/ItemView.swift deleted file mode 100644 index e1ccf7c0..00000000 --- a/jellypig iOS/Views/ItemView/ItemView.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct ItemView: View { - - protocol ScrollContainerView: View { - - associatedtype Content: View - - init(viewModel: ItemViewModel, content: @escaping () -> Content) - } - - @Default(.Customization.itemViewType) - private var itemViewType - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @StateObject - private var viewModel: ItemViewModel - @StateObject - private var deleteViewModel: DeleteItemViewModel - - @State - private var isPresentingConfirmationDialog = false - @State - private var isPresentingEventAlert = false - @State - private var error: JellyfinAPIError? - - // MARK: - Can Delete Item - - private var canDelete: Bool { - viewModel.userSession.user.permissions.items.canDelete(item: viewModel.item) - } - - // MARK: - Can Edit Item - - private var canEdit: Bool { - viewModel.userSession.user.permissions.items.canEditMetadata(item: viewModel.item) - // TODO: Enable when Subtitle / Lyric Editing is added - // || viewModel.userSession.user.permissions.items.canManageLyrics(item: viewModel.item) - // || viewModel.userSession.user.permissions.items.canManageSubtitles(item: viewModel.item) - } - - // MARK: - Deletion or Editing is Enabled - - private var enableMenu: Bool { - canEdit || canDelete - } - - private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel { - switch item.type { - case .boxSet: - return CollectionItemViewModel(item: item) - case .episode: - return EpisodeItemViewModel(item: item) - case .movie: - return MovieItemViewModel(item: item) - case .series: - return SeriesItemViewModel(item: item) - default: - assertionFailure("Unsupported item") - return ItemViewModel(item: item) - } - } - - init(item: BaseItemDto) { - self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item)) - self._deleteViewModel = StateObject(wrappedValue: DeleteItemViewModel(item: item)) - } - - @ViewBuilder - private var scrollContentView: some View { - switch viewModel.item.type { - case .boxSet: - CollectionItemContentView(viewModel: viewModel as! CollectionItemViewModel) - case .episode: - EpisodeItemContentView(viewModel: viewModel as! EpisodeItemViewModel) - case .movie: - MovieItemContentView(viewModel: viewModel as! MovieItemViewModel) - case .series: - SeriesItemContentView(viewModel: viewModel as! SeriesItemViewModel) - default: - Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--")) - } - } - - // TODO: break out into pad vs phone views based on item type - private func scrollContainerView( - viewModel: ItemViewModel, - content: @escaping () -> Content - ) -> any ScrollContainerView { - - if UIDevice.isPad { - return iPadOSCinematicScrollView(viewModel: viewModel, content: content) - } - - if viewModel.item.type == .movie || viewModel.item.type == .series { - switch itemViewType { - case .compactPoster: - return CompactPosterScrollView(viewModel: viewModel, content: content) - case .compactLogo: - return CompactLogoScrollView(viewModel: viewModel, content: content) - case .cinematic: - return CinematicScrollView(viewModel: viewModel, content: content) - } - } - - return SimpleScrollView(viewModel: viewModel, content: content) - } - - @ViewBuilder - private var innerBody: some View { - scrollContainerView(viewModel: viewModel) { - scrollContentView - } - .eraseToAnyView() - } - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - innerBody - .navigationTitle(viewModel.item.displayTitle) - case let .error(error): - ErrorView(error: error) - case .initial, .refreshing: - DelayedProgressView() - } - } - .transition(.opacity.animation(.linear(duration: 0.2))) - .navigationBarTitleDisplayMode(.inline) - .onFirstAppear { - viewModel.send(.refresh) - } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.refresh), - isHidden: !enableMenu - ) { - if canEdit { - Button(L10n.edit, systemImage: "pencil") { - router.route(to: \.itemEditor, viewModel) - } - } - - if canDelete { - Section { - Button(L10n.delete, systemImage: "trash", role: .destructive) { - isPresentingConfirmationDialog = true - } - } - } - } - .confirmationDialog( - L10n.deleteItemConfirmationMessage, - isPresented: $isPresentingConfirmationDialog, - titleVisibility: .visible - ) { - Button(L10n.confirm, role: .destructive) { - deleteViewModel.send(.delete) - } - Button(L10n.cancel, role: .cancel) {} - } - .onReceive(deleteViewModel.events) { event in - switch event { - case let .error(eventError): - error = eventError - isPresentingEventAlert = true - case .deleted: - router.dismissCoordinator() - } - } - .alert( - L10n.error, - isPresented: $isPresentingEventAlert, - presenting: error - ) { _ in - } message: { error in - Text(error.localizedDescription) - } - } -} diff --git a/jellypig iOS/Views/ItemView/MovieItemContentView.swift b/jellypig iOS/Views/ItemView/MovieItemContentView.swift deleted file mode 100644 index 17eec4a2..00000000 --- a/jellypig iOS/Views/ItemView/MovieItemContentView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct MovieItemContentView: View { - - @ObservedObject - var viewModel: MovieItemViewModel - - var body: some View { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - - // MARK: Genres - - if let genres = viewModel.item.itemGenres, genres.isNotEmpty { - ItemView.GenresHStack(genres: genres) - } - - // MARK: Studios - - if let studios = viewModel.item.studios, studios.isNotEmpty { - ItemView.StudiosHStack(studios: studios) - } - - // MARK: Cast and Crew - - if let castAndCrew = viewModel.item.people, - castAndCrew.isNotEmpty - { - ItemView.CastAndCrewHStack(people: castAndCrew) - } - - // MARK: Special Features - - if viewModel.specialFeatures.isNotEmpty { - ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) - } - - // MARK: Similar - - if viewModel.similarItems.isNotEmpty { - ItemView.SimilarItemsHStack(items: viewModel.similarItems) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/jellypig iOS/Views/ItemView/ScrollViews/CinematicScrollView.swift deleted file mode 100644 index 0ce8181f..00000000 --- a/jellypig iOS/Views/ItemView/ScrollViews/CinematicScrollView.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import BlurHashKit -import Defaults -import SwiftUI - -extension ItemView { - - struct CinematicScrollView: ScrollContainerView { - - @Default(.Customization.CinematicItemViewType.usePrimaryImage) - private var usePrimaryImage - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - private var viewModel: ItemViewModel - - private let blurHashBottomEdgeColor: Color - private let content: Content - - init( - viewModel: ItemViewModel, - content: @escaping () -> Content - ) { - if let backdropBlurHash = viewModel.item.blurHash(.backdrop) { - let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB - blurHashBottomEdgeColor = Color( - red: Double(bottomRGB.0), - green: Double(bottomRGB.1), - blue: Double(bottomRGB.2) - ) - } else { - blurHashBottomEdgeColor = Color.secondarySystemFill - } - - self.content = content() - self.viewModel = viewModel - } - - @ViewBuilder - private var headerView: some View { - ImageView(viewModel.item.imageSource( - usePrimaryImage ? .primary : .backdrop, - maxWidth: UIScreen.main.bounds.width - )) - .aspectRatio(usePrimaryImage ? (2 / 3) : 1.77, contentMode: .fill) - .frame(height: UIScreen.main.bounds.height * 0.6) - .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) - } - - var body: some View { - OffsetScrollView(headerHeight: 0.75) { - headerView - } overlay: { - VStack { - Spacer() - - OverlayView(viewModel: viewModel) - .edgePadding(.horizontal) - .padding(.bottom) - .background { - BlurView(style: .systemThinMaterialDark) - .mask { - LinearGradient( - stops: [ - .init(color: .white.opacity(0), location: 0), - .init(color: .white, location: 0.3), - .init(color: .white, location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - } - } - } - } content: { - content - .edgePadding(.vertical) - } - } - } -} - -extension ItemView.CinematicScrollView { - - struct OverlayView: View { - - @Default(.Customization.CinematicItemViewType.usePrimaryImage) - private var usePrimaryImage - - @EnvironmentObject - private var router: ItemCoordinator.Router - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - VStack(alignment: .center, spacing: 10) { - if !usePrimaryImage { - ImageView(viewModel.item.imageURL(.logo, maxHeight: 100)) - .placeholder { _ in - EmptyView() - } - .failure { - MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 100) - .font(.largeTitle.weight(.semibold)) - .lineLimit(2) - .multilineTextAlignment(.center) - .foregroundColor(.white) - } - .aspectRatio(contentMode: .fit) - .frame(height: 100, alignment: .bottom) - } - - DotHStack { - if let firstGenre = viewModel.item.genres?.first { - Text(firstGenre) - } - - if let premiereYear = viewModel.item.premiereDateYear { - Text(premiereYear) - } - - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { - Text(runtime) - } - } - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - .padding(.horizontal) - - Group { - if viewModel.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(height: 50) - } - - ItemView.ActionButtonHStack(viewModel: viewModel) - .font(.title) - .foregroundColor(.white) - } - .frame(maxWidth: 300) - } - .frame(maxWidth: .infinity) - - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(3) - .taglineLineLimit(2) - .foregroundColor(.white) - - ItemView.AttributesHStack(viewModel: viewModel, alignment: .leading) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/ScrollViews/CompactLogoScrollView.swift b/jellypig iOS/Views/ItemView/ScrollViews/CompactLogoScrollView.swift deleted file mode 100644 index 67f92f37..00000000 --- a/jellypig iOS/Views/ItemView/ScrollViews/CompactLogoScrollView.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import BlurHashKit -import SwiftUI - -extension ItemView { - - struct CompactLogoScrollView: ScrollContainerView { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - private var viewModel: ItemViewModel - - private let blurHashBottomEdgeColor: Color - private let content: Content - - init( - viewModel: ItemViewModel, - content: @escaping () -> Content - ) { - if let backdropBlurHash = viewModel.item.blurHash(.backdrop) { - let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB - blurHashBottomEdgeColor = Color( - red: Double(bottomRGB.0), - green: Double(bottomRGB.1), - blue: Double(bottomRGB.2) - ) - } else { - blurHashBottomEdgeColor = Color.secondarySystemFill - } - - self.content = content() - self.viewModel = viewModel - } - - @ViewBuilder - private var headerView: some View { - ImageView(viewModel.item.imageSource(.backdrop, maxHeight: UIScreen.main.bounds.height * 0.35)) - .aspectRatio(1.77, contentMode: .fill) - .frame(height: UIScreen.main.bounds.height * 0.35) - .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) - } - - var body: some View { - OffsetScrollView(headerHeight: 0.5) { - headerView - } overlay: { - VStack { - Spacer() - - OverlayView(viewModel: viewModel) - .padding(.horizontal) - .padding(.bottom) - .background { - BlurView(style: .systemThinMaterialDark) - .mask { - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black, location: 0.3), - ], - startPoint: .top, - endPoint: .bottom - ) - } - } - } - } content: { - VStack(alignment: .leading, spacing: 10) { - - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(4) - .taglineLineLimit(2) - .padding(.horizontal) - - RowDivider() - - content - } - .edgePadding(.vertical) - } - } - } -} - -extension ItemView.CompactLogoScrollView { - - struct OverlayView: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - VStack(alignment: .center, spacing: 10) { - ImageView(viewModel.item.imageURL(.logo, maxHeight: 70)) - .placeholder { _ in - EmptyView() - } - .failure { - MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 70) - .font(.largeTitle.weight(.semibold)) - .lineLimit(2) - .multilineTextAlignment(.center) - .foregroundColor(.white) - } - .aspectRatio(contentMode: .fit) - .frame(height: 70, alignment: .bottom) - - DotHStack { - if let firstGenre = viewModel.item.genres?.first { - Text(firstGenre) - } - - if let premiereYear = viewModel.item.premiereDateYear { - Text(premiereYear) - } - - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { - Text(runtime) - } - } - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - .padding(.horizontal) - - ItemView.AttributesHStack(viewModel: viewModel) - - Group { - if viewModel.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(height: 50) - } - - ItemView.ActionButtonHStack(viewModel: viewModel) - .font(.title) - .foregroundStyle(.white) - } - .frame(maxWidth: 300) - } - .frame(maxWidth: .infinity, alignment: .bottom) - } - } -} diff --git a/jellypig iOS/Views/ItemView/ScrollViews/CompactPortraitScrollView.swift b/jellypig iOS/Views/ItemView/ScrollViews/CompactPortraitScrollView.swift deleted file mode 100644 index fedf09c4..00000000 --- a/jellypig iOS/Views/ItemView/ScrollViews/CompactPortraitScrollView.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import BlurHashKit -import SwiftUI - -extension ItemView { - - struct CompactPosterScrollView: ScrollContainerView { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - private var viewModel: ItemViewModel - - private let blurHashBottomEdgeColor: Color - private let content: Content - - init( - viewModel: ItemViewModel, - content: @escaping () -> Content - ) { - if let backdropBlurHash = viewModel.item.blurHash(.backdrop) { - let bottomRGB = BlurHash(string: backdropBlurHash)!.averageLinearRGB - blurHashBottomEdgeColor = Color( - red: Double(bottomRGB.0), - green: Double(bottomRGB.1), - blue: Double(bottomRGB.2) - ) - } else { - blurHashBottomEdgeColor = Color.secondarySystemFill - } - - self.content = content() - self.viewModel = viewModel - } - - @ViewBuilder - private var headerView: some View { - ImageView(viewModel.item.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)) - .aspectRatio(1.77, contentMode: .fill) - .frame(height: UIScreen.main.bounds.height * 0.35) - .bottomEdgeGradient(bottomColor: blurHashBottomEdgeColor) - } - - var body: some View { - OffsetScrollView(headerHeight: 0.45) { - headerView - } overlay: { - VStack { - Spacer() - - OverlayView(viewModel: viewModel) - .edgePadding(.horizontal) - .edgePadding(.bottom) - .background { - BlurView(style: .systemThinMaterialDark) - .mask { - LinearGradient( - stops: [ - .init(color: .white.opacity(0), location: 0.2), - .init(color: .white.opacity(0.5), location: 0.3), - .init(color: .white, location: 0.55), - ], - startPoint: .top, - endPoint: .bottom - ) - } - } - } - } content: { - VStack(alignment: .leading, spacing: 10) { - - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(4) - .taglineLineLimit(2) - .padding(.horizontal) - - RowDivider() - - content - } - .edgePadding(.vertical) - } - } - } -} - -extension ItemView.CompactPosterScrollView { - - struct OverlayView: View { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - var viewModel: ItemViewModel - - @ViewBuilder - private var rightShelfView: some View { - VStack(alignment: .leading) { - - Text(viewModel.item.displayTitle) - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.white) - - DotHStack { - if viewModel.item.isUnaired { - if let premiereDateLabel = viewModel.item.airDateLabel { - Text(premiereDateLabel) - } - } else { - if let productionYear = viewModel.item.productionYear { - Text(String(productionYear)) - } - } - - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { - Text(runtime) - } - } - .lineLimit(1) - .font(.subheadline.weight(.medium)) - .foregroundColor(Color(UIColor.lightGray)) - - ItemView.AttributesHStack(viewModel: viewModel, alignment: .leading) - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .bottom, spacing: 12) { - - ImageView(viewModel.item.imageSource(.primary, maxWidth: 130)) - .failure { - SystemImageContentView(systemName: viewModel.item.systemImage) - } - .posterStyle(.portrait, contentMode: .fit) - .frame(width: 130) - .accessibilityIgnoresInvertColors() - - rightShelfView - .padding(.bottom) - } - - HStack(alignment: .center) { - - if viewModel.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(width: 130, height: 40) - } - - Spacer() - - ItemView.ActionButtonHStack(viewModel: viewModel, equalSpacing: false) - .font(.title2) - .foregroundColor(.white) - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/ScrollViews/SimpleScrollView.swift b/jellypig iOS/Views/ItemView/ScrollViews/SimpleScrollView.swift deleted file mode 100644 index 134650e5..00000000 --- a/jellypig iOS/Views/ItemView/ScrollViews/SimpleScrollView.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import BlurHashKit -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct SimpleScrollView: ScrollContainerView { - - @EnvironmentObject - private var router: ItemCoordinator.Router - - @ObservedObject - private var viewModel: ItemViewModel - - private let content: Content - - init( - viewModel: ItemViewModel, - @ViewBuilder content: () -> Content - ) { - self.content = content() - self.viewModel = viewModel - } - - @ViewBuilder - private var shelfView: some View { - VStack(alignment: .center, spacing: 10) { - if let parentTitle = viewModel.item.parentTitle { - Text(parentTitle) - .font(.headline) - .fontWeight(.semibold) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.horizontal) - .foregroundColor(.secondary) - } - - Text(viewModel.item.displayTitle) - .font(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.horizontal) - - DotHStack { - if let seasonEpisodeLabel = viewModel.item.seasonEpisodeLabel { - Text(seasonEpisodeLabel) - } - - if let productionYear = viewModel.item.premiereDateYear { - Text(productionYear) - } - - if let runtime = viewModel.item.runTimeLabel { - Text(runtime) - } - } - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal) - - ItemView.AttributesHStack(viewModel: viewModel, alignment: .center) - - Group { - if viewModel.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(height: 50) - } - - ItemView.ActionButtonHStack(viewModel: viewModel) - .font(.title) - .foregroundStyle(.primary) - } - .frame(maxWidth: 300) - } - } - - private var imageType: ImageType { - if viewModel.item.type == .episode { - return .primary - } else { - return .backdrop - } - } - - @ViewBuilder - private var header: some View { - VStack(alignment: .center) { - ImageView(viewModel.item.imageSource(imageType, maxWidth: 600)) - .placeholder { source in - if let blurHash = source.blurHash { - BlurHashView(blurHash: blurHash, size: .Square(length: 8)) - } else { - Color.secondarySystemFill - .opacity(0.75) - } - } - .failure { - SystemImageContentView(systemName: viewModel.item.systemImage) - } - .frame(maxHeight: 300) - .posterStyle(.landscape) - .posterShadow() - .padding(.horizontal) - - shelfView - } - } - - var body: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 10) { - - header - - // MARK: Overview - - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(4) - .padding(.horizontal) - - RowDivider() - - // MARK: Genres - - content - .edgePadding(.bottom) - } - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/ScrollViews/iPadOSCinematicScrollView.swift b/jellypig iOS/Views/ItemView/ScrollViews/iPadOSCinematicScrollView.swift deleted file mode 100644 index 6ab75199..00000000 --- a/jellypig iOS/Views/ItemView/ScrollViews/iPadOSCinematicScrollView.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -// TODO: remove rest occurrences of `UIDevice.main` sizings -// TODO: overlay spacing between overview and play button should be dynamic -// - smaller spacing on smaller widths (iPad Mini, portrait) - -// landscape vs portrait ratios just "feel right". Adjust if necessary -// or if a concrete design comes along. - -extension ItemView { - - struct iPadOSCinematicScrollView: ScrollContainerView { - - @ObservedObject - private var viewModel: ItemViewModel - - @State - private var globalSize: CGSize = .zero - - private let content: Content - - init( - viewModel: ItemViewModel, - @ViewBuilder content: () -> Content - ) { - self.content = content() - self.viewModel = viewModel - } - - private var imageType: ImageType { - if viewModel.item.type == .episode { - return .primary - } else { - return .backdrop - } - } - - var body: some View { - OffsetScrollView( - headerHeight: globalSize.isLandscape ? 0.75 : 0.6 - ) { - ImageView(viewModel.item.imageSource(imageType, maxWidth: 1920)) - .aspectRatio(1.77, contentMode: .fill) - } overlay: { - VStack(spacing: 0) { - Spacer() - - OverlayView(viewModel: viewModel) - .edgePadding() - } - .background { - BlurView(style: .systemThinMaterialDark) - .mask { - LinearGradient( - stops: [ - .init(color: .clear, location: 0.4), - .init(color: .white, location: 0.8), - ], - startPoint: .top, - endPoint: .bottom - ) - } - } - } content: { - content - .edgePadding(.vertical) - } - .trackingSize($globalSize) - } - } -} - -extension ItemView.iPadOSCinematicScrollView { - - struct OverlayView: View { - - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - HStack(alignment: .bottom) { - - VStack(alignment: .leading, spacing: 20) { - - ImageView(viewModel.item.imageSource( - .logo, - maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 130 - )) - .placeholder { _ in - EmptyView() - } - .failure { - Text(viewModel.item.displayTitle) - .font(.largeTitle) - .fontWeight(.semibold) - .lineLimit(2) - .multilineTextAlignment(.leading) - .foregroundColor(.white) - } - .aspectRatio(contentMode: .fit) - .frame(maxWidth: UIScreen.main.bounds.width * 0.4, maxHeight: 130, alignment: .bottomLeading) - - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(3) - .taglineLineLimit(2) - .foregroundColor(.white) - - HStack(spacing: 30) { - DotHStack { - if let firstGenre = viewModel.item.genres?.first { - Text(firstGenre) - } - - if let premiereYear = viewModel.item.premiereDateYear { - Text(premiereYear) - } - - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { - Text(runtime) - } - } - .font(.footnote) - .foregroundColor(Color(UIColor.lightGray)) - - ItemView.AttributesHStack(viewModel: viewModel, alignment: .leading) - } - } - .padding(.trailing, 200) - - Spacer() - - VStack(spacing: 10) { - if viewModel.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(height: 50) - } - - ItemView.ActionButtonHStack(viewModel: viewModel) - .font(.title) - .foregroundColor(.white) - } - .frame(width: 250) - } - } - } -} diff --git a/jellypig iOS/Views/ItemView/SeriesItemContentView.swift b/jellypig iOS/Views/ItemView/SeriesItemContentView.swift deleted file mode 100644 index 381aa4c4..00000000 --- a/jellypig iOS/Views/ItemView/SeriesItemContentView.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct SeriesItemContentView: View { - - @ObservedObject - var viewModel: SeriesItemViewModel - - var body: some View { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - - // MARK: Episodes - - if viewModel.seasons.isNotEmpty { - SeriesEpisodeSelector(viewModel: viewModel) - } - - // MARK: Genres - - if let genres = viewModel.item.itemGenres, genres.isNotEmpty { - ItemView.GenresHStack(genres: genres) - } - - // MARK: Studios - - if let studios = viewModel.item.studios, studios.isNotEmpty { - ItemView.StudiosHStack(studios: studios) - } - - // MARK: Cast and Crew - - if let castAndCrew = viewModel.item.people, - castAndCrew.isNotEmpty - { - ItemView.CastAndCrewHStack(people: castAndCrew) - } - - // MARK: Special Features - - if viewModel.specialFeatures.isNotEmpty { - ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) - } - - // MARK: Similar - - if viewModel.similarItems.isNotEmpty { - ItemView.SimilarItemsHStack(items: viewModel.similarItems) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/jellypig iOS/Views/MediaSourceInfoView.swift b/jellypig iOS/Views/MediaSourceInfoView.swift deleted file mode 100644 index 13f4969c..00000000 --- a/jellypig iOS/Views/MediaSourceInfoView.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -struct MediaSourceInfoView: View { - - @EnvironmentObject - private var router: MediaSourceInfoCoordinator.Router - - let source: MediaSourceInfo - - var body: some View { - Form { - if let videoStreams = source.videoStreams, - videoStreams.isNotEmpty - { - Section(L10n.video) { - ForEach(videoStreams, id: \.self) { stream in - ChevronButton(stream.displayTitle ?? .emptyDash) { - router.route(to: \.mediaStreamInfo, stream) - } - } - } - } - - if let audioStreams = source.audioStreams, - audioStreams.isNotEmpty - { - Section(L10n.audio) { - ForEach(audioStreams, id: \.self) { stream in - ChevronButton(stream.displayTitle ?? .emptyDash) { - router.route(to: \.mediaStreamInfo, stream) - } - } - } - } - - if let subtitleStreams = source.subtitleStreams, - subtitleStreams.isNotEmpty - { - Section(L10n.subtitle) { - ForEach(subtitleStreams, id: \.self) { stream in - ChevronButton(stream.displayTitle ?? .emptyDash) { - router.route(to: \.mediaStreamInfo, stream) - } - } - } - } - } - .navigationTitle(source.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - } -} diff --git a/jellypig iOS/Views/MediaStreamInfoView.swift b/jellypig iOS/Views/MediaStreamInfoView.swift deleted file mode 100644 index ac0f5d27..00000000 --- a/jellypig iOS/Views/MediaStreamInfoView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct MediaStreamInfoView: View { - - let mediaStream: MediaStream - - var body: some View { - Form { - Section { - ForEach(mediaStream.metadataProperties) { property in - TextPairView(property) - } - } - - if mediaStream.colorProperties.isNotEmpty { - Section(L10n.color) { - ForEach(mediaStream.colorProperties) { property in - TextPairView(property) - } - } - } - - if mediaStream.deliveryProperties.isNotEmpty { - Section(L10n.delivery) { - ForEach(mediaStream.deliveryProperties) { property in - TextPairView(property) - } - } - } - } - .navigationTitle(mediaStream.displayTitle ?? .emptyDash) - } -} diff --git a/jellypig iOS/Views/MediaView/Components/MediaItem.swift b/jellypig iOS/Views/MediaView/Components/MediaItem.swift deleted file mode 100644 index ef336d38..00000000 --- a/jellypig iOS/Views/MediaView/Components/MediaItem.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// Note: the design reason to not have a local label always on top -// is to have the same failure/empty color for all views -// TODO: why don't shadows work with failure image views? -// - due to `Color`? - -extension MediaView { - - // TODO: custom view for folders and tv (allow customization?) - // - differentiate between what media types are Swiftfin only - // which would allow some cleanup - // - allow server or random view per library? - // TODO: if local label on image, also needs to be in blurhash placeholder - struct MediaItem: View { - - @Default(.Customization.Library.randomImage) - private var useRandomImage - - @ObservedObject - var viewModel: MediaViewModel - - @State - private var imageSources: [ImageSource] = [] - - private var onSelect: () -> Void - private let mediaType: MediaViewModel.MediaType - - init(viewModel: MediaViewModel, type: MediaViewModel.MediaType) { - self.viewModel = viewModel - self.onSelect = {} - self.mediaType = type - } - - private var useTitleLabel: Bool { - useRandomImage || - mediaType == .downloads || - mediaType == .favorites - } - - private func setImageSources() { - Task { @MainActor in - if useRandomImage { - self.imageSources = try await viewModel.randomItemImageSources(for: mediaType) - return - } - - if case let MediaViewModel.MediaType.collectionFolder(item) = mediaType { - self.imageSources = [item.imageSource(.primary, maxWidth: 200)] - } else if case let MediaViewModel.MediaType.liveTV(item) = mediaType { - self.imageSources = [item.imageSource(.primary, maxWidth: 200)] - } - } - } - - @ViewBuilder - private var titleLabel: some View { - Text(mediaType.displayTitle) - .font(.title2) - .fontWeight(.semibold) - .lineLimit(1) - .multilineTextAlignment(.center) - .frame(alignment: .center) - } - - // TODO: find a different way to do this local-label-wackiness if possible - private func titleLabelOverlay(with content: Content) -> some View { - ZStack { - content - - Color.black - .opacity(0.5) - - titleLabel - .foregroundStyle(.white) - } - } - - var body: some View { - Button { - onSelect() - } label: { - ZStack { - Color.clear - - ImageView(imageSources) - .image { image in - if useTitleLabel { - titleLabelOverlay(with: image) - } else { - image - } - } - .placeholder { imageSource in - titleLabelOverlay(with: ImageView.DefaultPlaceholderView(blurHash: imageSource.blurHash)) - } - .failure { - Color.secondarySystemFill - .opacity(0.75) - .overlay { - titleLabel - .foregroundColor(.primary) - } - } - .id(imageSources.hashValue) - } - .posterStyle(.landscape) - .posterShadow() - } - .onFirstAppear(perform: setImageSources) - .onChange(of: useRandomImage) { _ in - setImageSources() - } - } - } -} - -extension MediaView.MediaItem { - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/jellypig iOS/Views/MediaView/MediaView.swift b/jellypig iOS/Views/MediaView/MediaView.swift deleted file mode 100644 index 45b192ac..00000000 --- a/jellypig iOS/Views/MediaView/MediaView.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionVGrid -import Defaults -import Factory -import JellyfinAPI -import Stinsen -import SwiftUI - -// TODO: seems to redraw view when popped to sometimes? -// - similar to HomeView TODO bug? -// TODO: list view -// TODO: `afterLastDisappear` with `backgroundRefresh` -struct MediaView: View { - - @EnvironmentObject - private var router: MediaCoordinator.Router - - @StateObject - private var viewModel = MediaViewModel() - - private var padLayout: CollectionVGridLayout { - .minWidth(200) - } - - private var phoneLayout: CollectionVGridLayout { - .columns(2) - } - - @ViewBuilder - private var contentView: some View { - CollectionVGrid( - uniqueElements: viewModel.mediaItems, - layout: UIDevice.isPhone ? phoneLayout : padLayout - ) { mediaType in - MediaItem(viewModel: viewModel, type: mediaType) - .onSelect { - switch mediaType { - case let .collectionFolder(item): - let viewModel = ItemLibraryViewModel( - parent: item, - filters: .default - ) - router.route(to: \.library, viewModel) - case .downloads: - router.route(to: \.downloads) - case .favorites: - // TODO: favorites should have its own view instead of a library - let viewModel = ItemLibraryViewModel( - title: L10n.favorites, - id: "favorites", - filters: .favorites - ) - router.route(to: \.library, viewModel) - case .liveTV: - router.route(to: \.liveTV) - } - } - } - } - - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - } - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - contentView - case let .error(error): - errorView(with: error) - case .initial, .refreshing: - DelayedProgressView() - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .ignoresSafeArea() - .navigationTitle(L10n.allMedia) - .topBarTrailing { - if viewModel.state == .refreshing { - ProgressView() - } - } - .onFirstAppear { - viewModel.send(.refresh) - } - } -} diff --git a/jellypig iOS/Views/PagingLibraryView/Components/LibraryRow.swift b/jellypig iOS/Views/PagingLibraryView/Components/LibraryRow.swift deleted file mode 100644 index 05fa1b2f..00000000 --- a/jellypig iOS/Views/PagingLibraryView/Components/LibraryRow.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -private let landscapeMaxWidth: CGFloat = 110 -private let portraitMaxWidth: CGFloat = 60 - -extension PagingLibraryView { - - struct LibraryRow: View { - - private let item: Element - private var action: () -> Void - private let posterType: PosterDisplayType - - private func imageView(from element: Element) -> ImageView { - switch posterType { - case .landscape: - ImageView(element.landscapeImageSources(maxWidth: landscapeMaxWidth)) - case .portrait: - ImageView(element.portraitImageSources(maxWidth: portraitMaxWidth)) - } - } - - @ViewBuilder - private func itemAccessoryView(item: BaseItemDto) -> some View { - DotHStack { - if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLabel { - Text(seasonEpisodeLocator) - } else if let premiereYear = item.premiereDateYear { - Text(premiereYear) - } - - if let runtime = item.runTimeLabel { - Text(runtime) - } - - if let officialRating = item.officialRating { - Text(officialRating) - } - } - } - - @ViewBuilder - private func personAccessoryView(person: BaseItemPerson) -> some View { - if let subtitle = person.subtitle { - Text(subtitle) - } - } - - @ViewBuilder - private var accessoryView: some View { - switch item { - case let element as BaseItemDto: - itemAccessoryView(item: element) - case let element as BaseItemPerson: - personAccessoryView(person: element) - default: - AssertionFailureView("Used an unexpected type within a `PagingLibaryView`?") - } - } - - @ViewBuilder - private var rowContent: some View { - HStack { - VStack(alignment: .leading, spacing: 5) { - Text(item.displayTitle) - .font(posterType == .landscape ? .subheadline : .callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(2) - .multilineTextAlignment(.leading) - - accessoryView - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - } - - Spacer() - } - } - - @ViewBuilder - private var rowLeading: some View { - ZStack { - Color.clear - - imageView(from: item) - .failure { - SystemImageContentView(systemName: item.systemImage) - } - } - .posterStyle(posterType) - .frame(width: posterType == .landscape ? landscapeMaxWidth : portraitMaxWidth) - .posterShadow() - .padding(.vertical, 8) - } - - // MARK: body - - var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { - rowLeading - } content: { - rowContent - } - .onSelect(perform: action) - } - } -} - -extension PagingLibraryView.LibraryRow { - - init(item: Element, posterType: PosterDisplayType) { - self.init( - item: item, - action: {}, - posterType: posterType - ) - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.action, with: action) - } -} diff --git a/jellypig iOS/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift b/jellypig iOS/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift deleted file mode 100644 index 002777ac..00000000 --- a/jellypig iOS/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// TODO: rename `LibraryDisplayTypeToggle`/Section -// - change to 2 Menu's in a section with subtitle -// like on `SelectUserView`? - -extension PagingLibraryView { - - struct LibraryViewTypeToggle: View { - - @Binding - private var listColumnCount: Int - @Binding - private var posterType: PosterDisplayType - @Binding - private var viewType: LibraryDisplayType - - init( - posterType: Binding, - viewType: Binding, - listColumnCount: Binding - ) { - self._listColumnCount = listColumnCount - self._posterType = posterType - self._viewType = viewType - } - - var body: some View { - Menu { - - Section(L10n.posters) { - Button { - posterType = .landscape - } label: { - if posterType == .landscape { - Label(L10n.landscape, systemImage: "checkmark") - } else { - Label(L10n.landscape, systemImage: "rectangle") - } - } - - Button { - posterType = .portrait - } label: { - if posterType == .portrait { - Label(L10n.portrait, systemImage: "checkmark") - } else { - Label(L10n.portrait, systemImage: "rectangle.portrait") - } - } - } - - Section(L10n.layout) { - Button { - viewType = .grid - } label: { - if viewType == .grid { - Label(L10n.grid, systemImage: "checkmark") - } else { - Label(L10n.grid, systemImage: "square.grid.2x2.fill") - } - } - - Button { - viewType = .list - } label: { - if viewType == .list { - Label(L10n.list, systemImage: "checkmark") - } else { - Label(L10n.list, systemImage: "square.fill.text.grid.1x2") - } - } - } - - if viewType == .list, UIDevice.isPad { - Stepper(L10n.columnsWithCount(listColumnCount), value: $listColumnCount, in: 1 ... 3) - } - } label: { - switch viewType { - case .grid: - Label(L10n.layout, systemImage: "square.grid.2x2.fill") - case .list: - Label(L10n.layout, systemImage: "square.fill.text.grid.1x2") - } - } - } - } -} diff --git a/jellypig iOS/Views/PagingLibraryView/PagingLibraryView.swift b/jellypig iOS/Views/PagingLibraryView/PagingLibraryView.swift deleted file mode 100644 index 1198d1cf..00000000 --- a/jellypig iOS/Views/PagingLibraryView/PagingLibraryView.swift +++ /dev/null @@ -1,498 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionVGrid -import Defaults -import JellyfinAPI -import SwiftUI - -// TODO: need to think about better design for views that may not support current library display type -// - ex: channels/albums when in portrait/landscape -// - just have the supported view embedded in a container view? -// TODO: could bottom (defaults + stored) `onChange` copies be cleaned up? -// - more could be cleaned up if there was a "switcher" property wrapper that takes two -// sources and a switch and holds the current expected value -// - or if Defaults values were moved to StoredValues and each key would return/respond to -// what values they should have -// TODO: when there are no filters sometimes navigation bar will be clear until popped back to - -/* - Note: Currently, it is a conscious decision to not have grid posters have subtitle content. - This is due to episodes, which have their `S_E_` subtitles, and these can be alongside - other items that don't have a subtitle which requires the entire library to implement - subtitle content but that doesn't look appealing. Until a solution arrives grid posters - will not have subtitle content. - There should be a solution since there are contexts where subtitles are desirable and/or - we can have subtitle content for other items. - - 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 complexity with what "actual" settings - should be applied. - */ - -struct PagingLibraryView: View { - - @Default(.Customization.Library.enabledDrawerFilters) - private var enabledDrawerFilters - @Default(.Customization.Library.rememberLayout) - private var rememberLayout - - @Default - private var defaultDisplayType: LibraryDisplayType - @Default - private var defaultListColumnCount: Int - @Default - private var defaultPosterType: PosterDisplayType - - @Default(.Customization.Library.letterPickerEnabled) - private var letterPickerEnabled - @Default(.Customization.Library.letterPickerOrientation) - private var letterPickerOrientation - - @EnvironmentObject - private var router: LibraryCoordinator.Router - - @State - private var layout: CollectionVGridLayout - @State - private var safeArea: EdgeInsets = .zero - - @StoredValue - private var displayType: LibraryDisplayType - @StoredValue - private var listColumnCount: Int - @StoredValue - private var posterType: PosterDisplayType - - @StateObject - private var collectionVGridProxy: CollectionVGridProxy = .init() - @StateObject - private var viewModel: PagingLibraryViewModel - - // MARK: init - - init(viewModel: PagingLibraryViewModel) { - - // have to set these properties manually to get proper initial layout - - self._defaultDisplayType = Default(.Customization.Library.displayType) - self._defaultListColumnCount = Default(.Customization.Library.listColumnCount) - self._defaultPosterType = Default(.Customization.Library.posterType) - - self._displayType = StoredValue(.User.libraryDisplayType(parentID: viewModel.parent?.id)) - self._listColumnCount = StoredValue(.User.libraryListColumnCount(parentID: viewModel.parent?.id)) - self._posterType = StoredValue(.User.libraryPosterType(parentID: viewModel.parent?.id)) - - self._viewModel = StateObject(wrappedValue: viewModel) - - let initialDisplayType = Defaults[.Customization.Library.rememberLayout] ? _displayType.wrappedValue : _defaultDisplayType - .wrappedValue - let initialListColumnCount = Defaults[.Customization.Library.rememberLayout] ? _listColumnCount - .wrappedValue : _defaultListColumnCount.wrappedValue - let initialPosterType = Defaults[.Customization.Library.rememberLayout] ? _posterType.wrappedValue : _defaultPosterType.wrappedValue - - if UIDevice.isPhone { - layout = Self.phoneLayout( - posterType: initialPosterType, - viewType: initialDisplayType - ) - } else { - layout = Self.padLayout( - posterType: initialPosterType, - viewType: initialDisplayType, - listColumnCount: initialListColumnCount - ) - } - } - - // MARK: onSelect - - private func onSelect(_ element: Element) { - switch element { - case let element as BaseItemDto: - select(item: element) - case let element as BaseItemPerson: - select(person: element) - default: - assertionFailure("Used an unexpected type within a `PagingLibaryView`?") - } - } - - private func select(item: BaseItemDto) { - switch item.type { - case .collectionFolder, .folder: - let viewModel = ItemLibraryViewModel(parent: item, filters: .default) - router.route(to: \.library, viewModel) - case .person: - let viewModel = ItemLibraryViewModel(parent: item) - router.route(to: \.library, viewModel) - default: - router.route(to: \.item, item) - } - } - - private func select(person: BaseItemPerson) { - let viewModel = ItemLibraryViewModel(parent: person) - router.route(to: \.library, viewModel) - } - - // MARK: layout - - // TODO: rename old "viewType" paramter to "displayType" and sort - - private static func padLayout( - posterType: PosterDisplayType, - viewType: LibraryDisplayType, - listColumnCount: Int - ) -> CollectionVGridLayout { - switch (posterType, viewType) { - case (.landscape, .grid): - .minWidth(200) - case (.portrait, .grid): - .minWidth(150) - case (_, .list): - .columns(listColumnCount, insets: .zero, itemSpacing: 0, lineSpacing: 0) - } - } - - private static func phoneLayout( - posterType: PosterDisplayType, - viewType: LibraryDisplayType - ) -> CollectionVGridLayout { - switch (posterType, viewType) { - case (.landscape, .grid): - .columns(2) - case (.portrait, .grid): - .columns(3) - case (_, .list): - .columns(1, insets: .zero, itemSpacing: 0, lineSpacing: 0) - } - } - - // MARK: item view - - // Note: if parent is a folders then other items will have labels, - // so an empty content view is necessary - - @ViewBuilder - private func landscapeGridItemView(item: Element) -> some View { - PosterButton(item: item, type: .landscape) - .content { - if item.showTitle { - PosterButton.TitleContentView(item: item) - .backport - .lineLimit(1, reservesSpace: true) - } else if viewModel.parent?.libraryType == .folder { - PosterButton.TitleContentView(item: item) - .backport - .lineLimit(1, reservesSpace: true) - .hidden() - } - } - .onSelect { - onSelect(item) - } - } - - @ViewBuilder - private func portraitGridItemView(item: Element) -> some View { - PosterButton(item: item, type: .portrait) - .content { - if item.showTitle { - PosterButton.TitleContentView(item: item) - .backport - .lineLimit(1, reservesSpace: true) - } else if viewModel.parent?.libraryType == .folder { - PosterButton.TitleContentView(item: item) - .backport - .lineLimit(1, reservesSpace: true) - .hidden() - } - } - .onSelect { - onSelect(item) - } - } - - @ViewBuilder - private func listItemView(item: Element, posterType: PosterDisplayType) -> some View { - LibraryRow(item: item, posterType: posterType) - .onSelect { - onSelect(item) - } - } - - @ViewBuilder - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - } - - @ViewBuilder - private var gridView: some View { - CollectionVGrid( - uniqueElements: viewModel.elements, - id: \.unwrappedIDHashOrZero, - layout: layout - ) { item in - - let displayType = Defaults[.Customization.Library.rememberLayout] ? _displayType.wrappedValue : _defaultDisplayType - .wrappedValue - let posterType = Defaults[.Customization.Library.rememberLayout] ? _posterType.wrappedValue : _defaultPosterType.wrappedValue - - switch (posterType, displayType) { - case (.landscape, .grid): - landscapeGridItemView(item: item) - case (.portrait, .grid): - portraitGridItemView(item: item) - case (_, .list): - listItemView(item: item, posterType: posterType) - } - } - .onReachedBottomEdge(offset: .offset(300)) { - viewModel.send(.getNextPage) - } - .proxy(collectionVGridProxy) - .scrollIndicatorsVisible(false) - } - - @ViewBuilder - private var innerContent: some View { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - L10n.noResults.text - } else { - gridView - } - case .initial, .refreshing: - DelayedProgressView() - default: - AssertionFailureView("Expected view for unexpected state") - } - } - - @ViewBuilder - private var contentView: some View { - if letterPickerEnabled, let filterViewModel = viewModel.filterViewModel { - ZStack(alignment: letterPickerOrientation.alignment) { - innerContent - .padding(letterPickerOrientation.edge, LetterPickerBar.size + 10) - .frame(maxWidth: .infinity) - - LetterPickerBar(viewModel: filterViewModel) - .padding(.top, safeArea.top) - .padding(.bottom, safeArea.bottom) - .padding(letterPickerOrientation.edge, 10) - } - } else { - innerContent - } - } - - // MARK: body - - // TODO: becoming too large for typechecker during development, should break up somehow - - var body: some View { - ZStack { - Color.clear - - switch viewModel.state { - case .content, .initial, .refreshing: - contentView - case let .error(error): - errorView(with: error) - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .ignoresSafeArea() - .onSizeChanged { _, safeArea in - self.safeArea = safeArea - } - .navigationTitle(viewModel.parent?.displayTitle ?? "") - .navigationBarTitleDisplayMode(.inline) - .ifLet(viewModel.filterViewModel) { view, filterViewModel in - view.navigationBarFilterDrawer( - viewModel: filterViewModel, - types: enabledDrawerFilters - ) { - router.route(to: \.filter, $0) - } - } - .onChange(of: defaultDisplayType) { newValue in - guard !Defaults[.Customization.Library.rememberLayout] else { return } - - if UIDevice.isPhone { - layout = Self.phoneLayout( - posterType: defaultPosterType, - viewType: newValue - ) - } else { - layout = Self.padLayout( - posterType: defaultPosterType, - viewType: newValue, - listColumnCount: defaultListColumnCount - ) - } - } - .onChange(of: defaultListColumnCount) { newValue in - guard !Defaults[.Customization.Library.rememberLayout] else { return } - - if UIDevice.isPad { - layout = Self.padLayout( - posterType: defaultPosterType, - viewType: defaultDisplayType, - listColumnCount: newValue - ) - } - } - .onChange(of: defaultPosterType) { newValue in - guard !Defaults[.Customization.Library.rememberLayout] else { return } - - if UIDevice.isPhone { - if defaultDisplayType == .list { - collectionVGridProxy.layout() - } else { - layout = Self.phoneLayout( - posterType: newValue, - viewType: defaultDisplayType - ) - } - } else { - if defaultDisplayType == .list { - collectionVGridProxy.layout() - } else { - layout = Self.padLayout( - posterType: newValue, - viewType: defaultDisplayType, - listColumnCount: defaultListColumnCount - ) - } - } - } - .onChange(of: displayType) { newValue in - if UIDevice.isPhone { - layout = Self.phoneLayout( - posterType: posterType, - viewType: newValue - ) - } else { - layout = Self.padLayout( - posterType: posterType, - viewType: newValue, - listColumnCount: listColumnCount - ) - } - } - .onChange(of: listColumnCount) { newValue in - if UIDevice.isPad { - layout = Self.padLayout( - posterType: posterType, - viewType: displayType, - listColumnCount: newValue - ) - } - } - .onChange(of: posterType) { newValue in - if UIDevice.isPhone { - if displayType == .list { - collectionVGridProxy.layout() - } else { - layout = Self.phoneLayout( - posterType: newValue, - viewType: displayType - ) - } - } else { - if displayType == .list { - collectionVGridProxy.layout() - } else { - layout = Self.padLayout( - posterType: newValue, - viewType: displayType, - listColumnCount: listColumnCount - ) - } - } - } - .onChange(of: rememberLayout) { newValue in - let newDisplayType = newValue ? displayType : defaultDisplayType - let newListColumnCount = newValue ? listColumnCount : defaultListColumnCount - let newPosterType = newValue ? posterType : defaultPosterType - - if UIDevice.isPhone { - layout = Self.phoneLayout( - posterType: newPosterType, - viewType: newDisplayType - ) - } else { - layout = Self.padLayout( - posterType: newPosterType, - viewType: newDisplayType, - listColumnCount: newListColumnCount - ) - } - } - .onChange(of: viewModel.filterViewModel?.currentFilters) { newValue in - guard let newValue, let id = viewModel.parent?.id else { return } - - if Defaults[.Customization.Library.rememberSort] { - let newStoredFilters = StoredValues[.User.libraryFilters(parentID: id)] - .mutating(\.sortBy, with: newValue.sortBy) - .mutating(\.sortOrder, with: newValue.sortOrder) - - StoredValues[.User.libraryFilters(parentID: id)] = newStoredFilters - } - } - .onReceive(viewModel.events) { event in - switch event { - case let .gotRandomItem(item): - switch item { - case let item as BaseItemDto: - router.route(to: \.item, item) - case let item as BaseItemPerson: - let viewModel = ItemLibraryViewModel(parent: item, filters: .default) - router.route(to: \.library, viewModel) - default: - assertionFailure("Used an unexpected type within a `PagingLibaryView`?") - } - } - } - .onFirstAppear { - if viewModel.state == .initial { - viewModel.send(.refresh) - } - } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.gettingNextPage) - ) { - if Defaults[.Customization.Library.rememberLayout] { - LibraryViewTypeToggle( - posterType: $posterType, - viewType: $displayType, - listColumnCount: $listColumnCount - ) - } else { - LibraryViewTypeToggle( - posterType: $defaultPosterType, - viewType: $defaultDisplayType, - listColumnCount: $defaultListColumnCount - ) - } - - Button(L10n.random, systemImage: "dice.fill") { - viewModel.send(.getRandomItem) - } - .disabled(viewModel.elements.isEmpty) - } - } -} diff --git a/jellypig iOS/Views/PhotoPickerView/Components/PhotoCropView.swift b/jellypig iOS/Views/PhotoPickerView/Components/PhotoCropView.swift deleted file mode 100644 index 611245c3..00000000 --- a/jellypig iOS/Views/PhotoPickerView/Components/PhotoCropView.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Mantis -import SwiftUI - -struct PhotoCropView: View { - - // MARK: - State, Observed, & Environment Objects - - @StateObject - private var proxy: _PhotoCropView.Proxy = .init() - - // MARK: - Image Variable - - let isSaving: Bool - let image: UIImage - let cropShape: Mantis.CropShapeType - let presetRatio: Mantis.PresetFixedRatioType - let onSave: (UIImage) -> Void - let onCancel: () -> Void - - // MARK: - Body - - var body: some View { - _PhotoCropView( - initialImage: image, - cropShape: cropShape, - presetRatio: presetRatio, - proxy: proxy, - onImageCropped: onSave - ) - .topBarTrailing { - - Button(L10n.rotate, systemImage: "rotate.right") { - proxy.rotate() - } - - if isSaving { - Button(L10n.cancel, action: onCancel) - .buttonStyle(.toolbarPill(.red)) - } else { - Button(L10n.save) { - proxy.crop() - } - .buttonStyle(.toolbarPill) - } - } - .toolbar { - ToolbarItem(placement: .principal) { - if isSaving { - ProgressView() - } else { - Button(L10n.reset) { - proxy.reset() - } - .foregroundStyle(.yellow) - .disabled(isSaving) - } - } - } - .ignoresSafeArea() - .background { - Color.black - } - } -} - -// MARK: - Photo Crop View - -private struct _PhotoCropView: UIViewControllerRepresentable { - - class Proxy: ObservableObject { - - weak var cropViewController: CropViewController? - - func crop() { - cropViewController?.crop() - } - - func reset() { - cropViewController?.didSelectReset() - } - - func rotate() { - cropViewController?.didSelectClockwiseRotate() - } - } - - let initialImage: UIImage - let cropShape: Mantis.CropShapeType - let presetRatio: Mantis.PresetFixedRatioType - let proxy: Proxy - let onImageCropped: (UIImage) -> Void - - func makeUIViewController(context: Context) -> some UIViewController { - var config = Mantis.Config() - - config.cropViewConfig.backgroundColor = .black.withAlphaComponent(0.9) - config.cropViewConfig.cropShapeType = cropShape - config.presetFixedRatioType = presetRatio - config.showAttachedCropToolbar = false - - let cropViewController = Mantis.cropViewController( - image: initialImage, - config: config - ) - - cropViewController.delegate = context.coordinator - context.coordinator.onImageCropped = onImageCropped - - proxy.cropViewController = cropViewController - - return cropViewController - } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - class Coordinator: CropViewControllerDelegate { - - var onImageCropped: ((UIImage) -> Void)? - - func cropViewControllerDidCrop( - _ cropViewController: CropViewController, - cropped: UIImage, - transformation: Transformation, - cropInfo: CropInfo - ) { - onImageCropped?(cropped) - } - - func cropViewControllerDidCancel( - _ cropViewController: CropViewController, - original: UIImage - ) {} - - func cropViewControllerDidFailToCrop( - _ cropViewController: CropViewController, - original: UIImage - ) {} - - func cropViewControllerDidBeginResize( - _ cropViewController: CropViewController - ) {} - - func cropViewControllerDidEndResize( - _ cropViewController: Mantis.CropViewController, - original: UIImage, - cropInfo: Mantis.CropInfo - ) {} - } -} diff --git a/jellypig iOS/Views/PhotoPickerView/PhotoPickerView.swift b/jellypig iOS/Views/PhotoPickerView/PhotoPickerView.swift deleted file mode 100644 index a95e0108..00000000 --- a/jellypig iOS/Views/PhotoPickerView/PhotoPickerView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import PhotosUI -import SwiftUI - -// TODO: polish: find way to deselect image on appear -// - from popping from cropping -// TODO: polish: when image is picked, instead of loading it here -// which takes ~1-2s, show some kind of loading indicator -// on this view or push to another view that will go to crop - -struct PhotoPickerView: UIViewControllerRepresentable { - - // MARK: - Photo Picker Actions - - var onSelect: (UIImage) -> Void - var onCancel: () -> Void - - // MARK: - Initializer - - init(onSelect: @escaping (UIImage) -> Void, onCancel: @escaping () -> Void) { - self.onSelect = onSelect - self.onCancel = onCancel - } - - // MARK: - UIView Controller - - func makeUIViewController(context: Context) -> PHPickerViewController { - - var configuration = PHPickerConfiguration(photoLibrary: .shared()) - - configuration.filter = .all(of: [.images, .not(.livePhotos)]) - configuration.preferredAssetRepresentationMode = .current - configuration.selection = .default - configuration.selectionLimit = 1 - - let picker = PHPickerViewController(configuration: configuration) - picker.delegate = context.coordinator - - context.coordinator.onSelect = onSelect - context.coordinator.onCancel = onCancel - - return picker - } - - // MARK: - Update UIView Controller - - func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} - - // MARK: - Make Coordinator - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - // MARK: - Coordinator - - class Coordinator: PHPickerViewControllerDelegate { - - var onSelect: ((UIImage) -> Void)? - var onCancel: (() -> Void)? - - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - - guard let image = results.first else { - onCancel?() - return - } - - let itemProvider = image.itemProvider - - guard itemProvider.canLoadObject(ofClass: UIImage.self) else { return } - - itemProvider.loadObject(ofClass: UIImage.self) { image, _ in - guard let image = image as? UIImage else { return } - self.onSelect?(image) - } - } - } -} diff --git a/jellypig iOS/Views/ProgramsView/Components/ProgramButtonContent.swift b/jellypig iOS/Views/ProgramsView/Components/ProgramButtonContent.swift deleted file mode 100644 index 5d65d102..00000000 --- a/jellypig iOS/Views/ProgramsView/Components/ProgramButtonContent.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension ProgramsView { - - struct ProgramButtonContent: View { - - let program: BaseItemDto - - var body: some View { - VStack(alignment: .leading) { - - Text(program.channelName ?? .emptyDash) - .font(.footnote.weight(.semibold)) - .foregroundColor(.primary) - .backport - .lineLimit(1, reservesSpace: true) - - Text(program.displayTitle) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - .backport - .lineLimit(1, reservesSpace: true) - - HStack(spacing: 2) { - if let startDate = program.startDate { - Text(startDate, style: .time) - } else { - Text(String.emptyDash) - } - - Text("-") - - if let endDate = program.endDate { - Text(endDate, style: .time) - } else { - Text(String.emptyDash) - } - } - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } -} diff --git a/jellypig iOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/jellypig iOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift deleted file mode 100644 index a1adf0ee..00000000 --- a/jellypig iOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -// TODO: item-type dependent views may be more appropriate near/on -// the `PosterButton` object instead of on these larger views -extension ProgramsView { - - struct ProgramProgressOverlay: View { - - @State - private var programProgress: Double = 0.0 - - let program: BaseItemDto - private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - - var body: some View { - WrappedView { - if let startDate = program.startDate, startDate < Date.now { - LandscapePosterProgressBar( - progress: program.programProgress ?? 0 - ) - } - } - .onReceive(timer) { newValue in - if let startDate = program.startDate, startDate < newValue, let duration = program.programDuration { - programProgress = newValue.timeIntervalSince(startDate) / duration - } - } - } - } -} diff --git a/jellypig iOS/Views/ProgramsView/ProgramsView.swift b/jellypig iOS/Views/ProgramsView/ProgramsView.swift deleted file mode 100644 index f4c44ca9..00000000 --- a/jellypig iOS/Views/ProgramsView/ProgramsView.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -// TODO: background refresh for programs with timer? -// TODO: find other another way to handle channels/other views? - -// Note: there are some unsafe first element accesses, but `ChannelProgram` data should always have a single program - -struct ProgramsView: View { - - @EnvironmentObject - private var mainRouter: MainCoordinator.Router - @EnvironmentObject - private var router: LiveTVCoordinator.Router - - @StateObject - private var programsViewModel = ProgramsViewModel() - - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - programsViewModel.send(.refresh) - } - } - - @ViewBuilder - private var liveTVSectionScrollView: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - liveTVSectionPill( - title: L10n.channels, - systemImage: "play.square.stack" - ) { - router.route(to: \.channels) - } - } - .edgePadding(.horizontal) - } - } - - // TODO: probably make own pill view - // - see if could merge with item view pills - @ViewBuilder - private func liveTVSectionPill(title: String, systemImage: String, onSelect: @escaping () -> Void) -> some View { - Button { - onSelect() - } label: { - Label(title, systemImage: systemImage) - .font(.callout.weight(.semibold)) - .foregroundColor(.primary) - .padding(8) - .background { - Color.systemFill - .cornerRadius(10) - } - } - } - - @ViewBuilder - private var contentView: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 20) { - - liveTVSectionScrollView - - if programsViewModel.hasNoResults { - // TODO: probably change to "No Programs" - L10n.noResults.text - } - - if programsViewModel.recommended.isNotEmpty { - programsSection(title: L10n.onNow, keyPath: \.recommended) - } - - if programsViewModel.series.isNotEmpty { - programsSection(title: L10n.series, keyPath: \.series) - } - - if programsViewModel.movies.isNotEmpty { - programsSection(title: L10n.movies, keyPath: \.movies) - } - - if programsViewModel.kids.isNotEmpty { - programsSection(title: L10n.kids, keyPath: \.kids) - } - - if programsViewModel.sports.isNotEmpty { - programsSection(title: L10n.sports, keyPath: \.sports) - } - - if programsViewModel.news.isNotEmpty { - programsSection(title: L10n.news, keyPath: \.news) - } - } - } - } - - @ViewBuilder - private func programsSection( - title: String, - keyPath: KeyPath - ) -> some View { - PosterHStack( - title: title, - type: .landscape, - items: programsViewModel[keyPath: keyPath] - ) - .content { - ProgramButtonContent(program: $0) - } - .imageOverlay { - ProgramProgressOverlay(program: $0) - } - .onSelect { - mainRouter.route( - to: \.liveVideoPlayer, - LiveVideoPlayerManager(program: $0) - ) - } - } - - var body: some View { - WrappedView { - switch programsViewModel.state { - case .content: - contentView - case let .error(error): - errorView(with: error) - case .initial, .refreshing: - DelayedProgressView() - } - } - .navigationTitle(L10n.liveTV) - .navigationBarTitleDisplayMode(.inline) - .onFirstAppear { - if programsViewModel.state == .initial { - programsViewModel.send(.refresh) - } - } - } -} diff --git a/jellypig iOS/Views/QuickConnectView.swift b/jellypig iOS/Views/QuickConnectView.swift deleted file mode 100644 index 3ceb286a..00000000 --- a/jellypig iOS/Views/QuickConnectView.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct QuickConnectView: View { - - @EnvironmentObject - private var router: UserSignInCoordinator.Router - - @ObservedObject - private var viewModel: QuickConnect - - init(quickConnect: QuickConnect) { - self.viewModel = quickConnect - } - - private func pollingView(code: String) -> some View { - VStack(alignment: .leading, spacing: 20) { - BulletedList { - L10n.quickConnectStep1.text - .padding(.bottom) - L10n.quickConnectStep2.text - .padding(.bottom) - L10n.quickConnectStep3.text - .padding(.bottom) - } - - Text(code) - .tracking(10) - .font(.largeTitle) - .monospacedDigit() - .frame(maxWidth: .infinity) - - Spacer() - } - .frame(maxWidth: .infinity) - .edgePadding() - } - - var body: some View { - WrappedView { - switch viewModel.state { - case .idle, .authenticated: - Color.clear - case .retrievingCode: - ProgressView() - case let .polling(code): - pollingView(code: code) - case let .error(error): - ErrorView(error: error) - } - } - .edgePadding() - .navigationTitle(L10n.quickConnect) - .navigationBarTitleDisplayMode(.inline) - .onFirstAppear { - viewModel.start() - } - .onDisappear { - viewModel.stop() - } - .navigationBarCloseButton { - router.popLast() - } - } -} diff --git a/jellypig iOS/Views/ResetUserPasswordView/ResetUserPasswordView.swift b/jellypig iOS/Views/ResetUserPasswordView/ResetUserPasswordView.swift deleted file mode 100644 index 2aeca1f1..00000000 --- a/jellypig iOS/Views/ResetUserPasswordView/ResetUserPasswordView.swift +++ /dev/null @@ -1,179 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct ResetUserPasswordView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Focus Fields - - private enum Field: Hashable { - case currentPassword - case newPassword - case confirmNewPassword - } - - @FocusState - private var focusedField: Field? - - // MARK: - State & Environment Objects - - @EnvironmentObject - private var router: BasicNavigationViewCoordinator.Router - - @StateObject - private var viewModel: ResetUserPasswordViewModel - - // MARK: - Password Variables - - @State - private var currentPassword: String = "" - @State - private var newPassword: String = "" - @State - private var confirmNewPassword: String = "" - - private let requiresCurrentPassword: Bool - - // MARK: - Dialog States - - @State - private var isPresentingSuccess: Bool = false - - // MARK: - Error State - - @State - private var error: Error? = nil - - // MARK: - Initializer - - init(userID: String, requiresCurrentPassword: Bool) { - self._viewModel = StateObject(wrappedValue: ResetUserPasswordViewModel(userID: userID)) - self.requiresCurrentPassword = requiresCurrentPassword - } - - // MARK: - Body - - var body: some View { - List { - if requiresCurrentPassword { - Section(L10n.currentPassword) { - UnmaskSecureField(L10n.currentPassword, text: $currentPassword) { - focusedField = .newPassword - } - .autocorrectionDisabled() - .textInputAutocapitalization(.none) - .focused($focusedField, equals: .currentPassword) - .disabled(viewModel.state == .resetting) - } - } - - Section(L10n.newPassword) { - UnmaskSecureField(L10n.newPassword, text: $newPassword) { - focusedField = .confirmNewPassword - } - .autocorrectionDisabled() - .textInputAutocapitalization(.none) - .focused($focusedField, equals: .newPassword) - .disabled(viewModel.state == .resetting) - } - - Section { - UnmaskSecureField(L10n.confirmNewPassword, text: $confirmNewPassword) { - viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) - } - .autocorrectionDisabled() - .textInputAutocapitalization(.none) - .focused($focusedField, equals: .confirmNewPassword) - .disabled(viewModel.state == .resetting) - } header: { - Text(L10n.confirmNewPassword) - } footer: { - if newPassword != confirmNewPassword { - Label(L10n.passwordsDoNotMatch, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } - } - - Section { - if viewModel.state == .resetting { - ListRowButton(L10n.cancel) { - viewModel.send(.cancel) - - if requiresCurrentPassword { - focusedField = .currentPassword - } else { - focusedField = .newPassword - } - } - .foregroundStyle(.red, .red.opacity(0.2)) - } else { - ListRowButton(L10n.save) { - focusedField = nil - viewModel.send(.reset(current: currentPassword, new: confirmNewPassword)) - } - .disabled(newPassword != confirmNewPassword || viewModel.state == .resetting) - .foregroundStyle(accentColor.overlayColor, accentColor) - .opacity(newPassword != confirmNewPassword ? 0.5 : 1) - } - } footer: { - Text(L10n.passwordChangeWarning) - } - } - .interactiveDismissDisabled(viewModel.state == .resetting) - .navigationBarBackButtonHidden(viewModel.state == .resetting) - .navigationTitle(L10n.password) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - .onFirstAppear { - if requiresCurrentPassword { - focusedField = .currentPassword - } else { - focusedField = .newPassword - } - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .success: - UIDevice.feedback(.success) - isPresentingSuccess = true - } - } - .topBarTrailing { - if viewModel.state == .resetting { - ProgressView() - } - } - .alert( - L10n.success, - isPresented: $isPresentingSuccess - ) { - Button(L10n.dismiss, role: .cancel) { - router.dismissCoordinator() - } - } message: { - Text(L10n.passwordChangedMessage) - } - .errorMessage($error) { - focusedField = .newPassword - } - } -} diff --git a/jellypig iOS/Views/SearchView.swift b/jellypig iOS/Views/SearchView.swift deleted file mode 100644 index a5d047d3..00000000 --- a/jellypig iOS/Views/SearchView.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -// TODO: have a `SearchLibraryViewModel` that allows paging on searched items? -// TODO: implement search view result type between `PosterHStack` -// and `ListHStack` (3 row list columns)? (iOS only) -// TODO: have programs only pull recommended/current? -// - have progress overlay -struct SearchView: View { - - @Default(.Customization.Search.enabledDrawerFilters) - private var enabledDrawerFilters - @Default(.Customization.searchPosterType) - private var searchPosterType - - @EnvironmentObject - private var mainRouter: MainCoordinator.Router - @EnvironmentObject - private var router: SearchCoordinator.Router - - @State - private var searchQuery = "" - - @StateObject - private var viewModel = SearchViewModel() - - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.search(query: searchQuery)) - } - } - - @ViewBuilder - private var suggestionsView: some View { - VStack(spacing: 20) { - ForEach(viewModel.suggestions) { item in - Button(item.displayTitle) { - searchQuery = item.displayTitle - } - } - } - } - - @ViewBuilder - private var resultsView: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 20) { - if viewModel.movies.isNotEmpty { - itemsSection(title: L10n.movies, keyPath: \.movies, posterType: searchPosterType) - } - - if viewModel.series.isNotEmpty { - itemsSection(title: L10n.tvShows, keyPath: \.series, posterType: searchPosterType) - } - - if viewModel.collections.isNotEmpty { - itemsSection(title: L10n.collections, keyPath: \.collections, posterType: searchPosterType) - } - - if viewModel.episodes.isNotEmpty { - itemsSection(title: L10n.episodes, keyPath: \.episodes, posterType: searchPosterType) - } - - if viewModel.programs.isNotEmpty { - itemsSection(title: L10n.programs, keyPath: \.programs, posterType: .landscape) - } - - if viewModel.channels.isNotEmpty { - itemsSection(title: L10n.channels, keyPath: \.channels, posterType: .portrait) - } - - if viewModel.people.isNotEmpty { - itemsSection(title: L10n.people, keyPath: \.people, posterType: .portrait) - } - } - .edgePadding(.vertical) - } - } - - private func select(_ item: BaseItemDto) { - switch item.type { - case .person: - let viewModel = ItemLibraryViewModel(parent: item) - router.route(to: \.library, viewModel) - case .program: - mainRouter.route( - to: \.liveVideoPlayer, - LiveVideoPlayerManager(program: item) - ) - case .tvChannel: - guard let mediaSource = item.mediaSources?.first else { return } - mainRouter.route( - to: \.liveVideoPlayer, - LiveVideoPlayerManager(item: item, mediaSource: mediaSource) - ) - default: - router.route(to: \.item, item) - } - } - - @ViewBuilder - private func itemsSection( - title: String, - keyPath: KeyPath, - posterType: PosterDisplayType - ) -> some View { - PosterHStack( - title: title, - type: posterType, - items: viewModel[keyPath: keyPath] - ) - .trailing { - SeeAllButton() - .onSelect { - let viewModel = PagingLibraryViewModel( - title: title, - id: "search-\(keyPath.hashValue)", - viewModel[keyPath: keyPath] - ) - router.route(to: \.library, viewModel) - } - } - .onSelect(select) - } - - var body: some View { - WrappedView { - Group { - switch viewModel.state { - case let .error(error): - errorView(with: error) - case .initial: - suggestionsView - case .content: - if viewModel.hasNoResults { - L10n.noResults.text - } else { - resultsView - } - case .searching: - ProgressView() - } - } - .transition(.opacity.animation(.linear(duration: 0.1))) - } - .ignoresSafeArea(.keyboard, edges: .bottom) - .navigationTitle(L10n.search) - .navigationBarTitleDisplayMode(.inline) - .navigationBarFilterDrawer( - viewModel: viewModel.filterViewModel, - types: enabledDrawerFilters - ) { - router.route(to: \.filter, $0) - } - .onFirstAppear { - viewModel.send(.getSuggestions) - } - .onChange(of: searchQuery) { newValue in - viewModel.send(.search(query: newValue)) - } - .searchable( - text: $searchQuery, - placement: .navigationBarDrawer(displayMode: .always), - prompt: L10n.search - ) - } -} diff --git a/jellypig iOS/Views/SelectUserView/Components/AddUserGridButton.swift b/jellypig iOS/Views/SelectUserView/Components/AddUserGridButton.swift deleted file mode 100644 index 847bc7ac..00000000 --- a/jellypig iOS/Views/SelectUserView/Components/AddUserGridButton.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import OrderedCollections -import SwiftUI - -extension SelectUserView { - - struct AddUserGridButton: View { - - @Environment(\.colorScheme) - private var colorScheme - @Environment(\.isEnabled) - private var isEnabled - - let selectedServer: ServerState? - let servers: OrderedSet - let action: (ServerState) -> Void - - @ViewBuilder - private var label: some View { - VStack(alignment: .center) { - ZStack { - Group { - if colorScheme == .light { - Color.secondarySystemFill - } else { - Color.tertiarySystemBackground - } - } - .posterShadow() - - RelativeSystemImageView(systemName: "plus") - .foregroundStyle(.secondary) - } - .clipShape(.circle) - .aspectRatio(1, contentMode: .fill) - - Text(L10n.addUser) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(isEnabled ? .primary : .secondary) - - if selectedServer == nil { - // For layout, not to be localized - Text("Hidden") - .font(.footnote) - .hidden() - } - } - } - - var body: some View { - ConditionalMenu( - tracking: selectedServer, - action: action - ) { - Text(L10n.selectServer) - - ForEach(servers) { server in - Button { - action(server) - } label: { - Text(server.name) - Text(server.currentURL.absoluteString) - } - } - } label: { - label - } - .buttonStyle(.plain) - } - } -} diff --git a/jellypig iOS/Views/SelectUserView/Components/AddUserListRow.swift b/jellypig iOS/Views/SelectUserView/Components/AddUserListRow.swift deleted file mode 100644 index 186a6dbf..00000000 --- a/jellypig iOS/Views/SelectUserView/Components/AddUserListRow.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import OrderedCollections -import SwiftUI - -extension SelectUserView { - - struct AddUserListRow: View { - - @Environment(\.colorScheme) - private var colorScheme - @Environment(\.isEnabled) - private var isEnabled - - let selectedServer: ServerState? - let servers: OrderedSet - let action: (ServerState) -> Void - - @ViewBuilder - private var rowContent: some View { - Text(L10n.addUser) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(isEnabled ? .primary : .secondary) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private var rowLeading: some View { - ZStack { - Group { - if colorScheme == .light { - Color.secondarySystemFill - } else { - Color.tertiarySystemBackground - } - } - .posterShadow() - - RelativeSystemImageView(systemName: "plus") - .foregroundStyle(.secondary) - } - .aspectRatio(1, contentMode: .fill) - .clipShape(.circle) - .frame(width: 80) - .padding(.vertical, 8) - } - - @ViewBuilder - private var label: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { - rowLeading - } content: { - rowContent - } - .isSeparatorVisible(false) - .onSelect { - if let selectedServer { - action(selectedServer) - } - } - } - - var body: some View { - ConditionalMenu( - tracking: selectedServer, - action: action - ) { - Text(L10n.selectServer) - - ForEach(servers) { server in - Button { - action(server) - } label: { - Text(server.name) - Text(server.currentURL.absoluteString) - } - } - } label: { - label - } - } - } -} diff --git a/jellypig iOS/Views/SelectUserView/Components/ServerSelectionMenu.swift b/jellypig iOS/Views/SelectUserView/Components/ServerSelectionMenu.swift deleted file mode 100644 index 16e1609e..00000000 --- a/jellypig iOS/Views/SelectUserView/Components/ServerSelectionMenu.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import OrderedCollections -import SwiftUI - -extension SelectUserView { - - struct ServerSelectionMenu: View { - - @Environment(\.colorScheme) - private var colorScheme - - @EnvironmentObject - private var router: SelectUserCoordinator.Router - - @Binding - private var serverSelection: SelectUserServerSelection - - let selectedServer: ServerState? - let servers: OrderedSet - - init( - selection: Binding, - selectedServer: ServerState?, - servers: OrderedSet - ) { - self._serverSelection = selection - self.selectedServer = selectedServer - self.servers = servers - } - - var body: some View { - Menu { - Section { - Button(L10n.addServer, systemImage: "plus") { - router.route(to: \.connectToServer) - } - - if let selectedServer { - Button(L10n.editServer, systemImage: "server.rack") { - router.route(to: \.editServer, selectedServer) - } - } - } - - Picker(L10n.servers, selection: _serverSelection) { - - if servers.count > 1 { - Label(L10n.allServers, systemImage: "person.2.fill") - .tag(SelectUserServerSelection.all) - } - - ForEach(servers.reversed()) { server in - Button { - Text(server.name) - Text(server.currentURL.absoluteString) - } - .tag(SelectUserServerSelection.server(id: server.id)) - } - } - } label: { - ZStack { - - if colorScheme == .light { - Color.secondarySystemFill - } else { - Color.tertiarySystemBackground - } - - HStack { - switch serverSelection { - case .all: - Label(L10n.allServers, systemImage: "person.2.fill") - case let .server(id): - if let server = servers.first(where: { $0.id == id }) { - Label(server.name, systemImage: "server.rack") - } - } - - Image(systemName: "chevron.up.chevron.down") - .foregroundStyle(.secondary) - .font(.subheadline.weight(.semibold)) - } - .font(.body.weight(.semibold)) - .foregroundStyle(Color.primary) - } - .frame(height: 50) - .frame(maxWidth: 400) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - .buttonStyle(.plain) - } - } -} diff --git a/jellypig iOS/Views/SelectUserView/Components/UserGridButton.swift b/jellypig iOS/Views/SelectUserView/Components/UserGridButton.swift deleted file mode 100644 index e67f9959..00000000 --- a/jellypig iOS/Views/SelectUserView/Components/UserGridButton.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension SelectUserView { - - struct UserGridButton: View { - - @Default(.accentColor) - private var accentColor - - @Environment(\.isEditing) - private var isEditing - @Environment(\.isSelected) - private var isSelected - - private let user: UserState - private let server: ServerState - private let showServer: Bool - private let action: () -> Void - private let onDelete: () -> Void - - init( - user: UserState, - server: ServerState, - showServer: Bool, - action: @escaping () -> Void, - onDelete: @escaping () -> Void - ) { - self.user = user - self.server = server - self.showServer = showServer - self.action = action - self.onDelete = onDelete - } - - private var labelForegroundStyle: some ShapeStyle { - guard isEditing else { return .primary } - - return isSelected ? .primary : .secondary - } - - var body: some View { - Button(action: action) { - VStack { - UserProfileImage( - userID: user.id, - source: user.profileImageSource( - client: server.client, - maxWidth: 120 - ), - pipeline: .Swiftfin.local - ) - .overlay(alignment: .bottomTrailing) { - if isEditing, isSelected { - Image(systemName: "checkmark.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 40, height: 40, alignment: .bottomTrailing) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - } - } - - Text(user.username) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(labelForegroundStyle) - .lineLimit(1) - - if showServer { - Text(server.name) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - .buttonStyle(.plain) - .contextMenu { - if !isEditing { - Button( - L10n.delete, - role: .destructive, - action: onDelete - ) - } - } - } - } -} diff --git a/jellypig iOS/Views/SelectUserView/Components/UserListRow.swift b/jellypig iOS/Views/SelectUserView/Components/UserListRow.swift deleted file mode 100644 index 3c9fea55..00000000 --- a/jellypig iOS/Views/SelectUserView/Components/UserListRow.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI - -extension SelectUserView { - - struct UserListRow: View { - - @Default(.accentColor) - private var accentColor - - @Environment(\.colorScheme) - private var colorScheme - @Environment(\.isEditing) - private var isEditing - @Environment(\.isSelected) - private var isSelected - - private let user: UserState - private let server: ServerState - private let showServer: Bool - private let action: () -> Void - private let onDelete: () -> Void - - init( - user: UserState, - server: ServerState, - showServer: Bool, - action: @escaping () -> Void, - onDelete: @escaping () -> Void - ) { - self.user = user - self.server = server - self.showServer = showServer - self.action = action - self.onDelete = onDelete - } - - private var labelForegroundStyle: some ShapeStyle { - guard isEditing else { return .primary } - - return isSelected ? .primary : .secondary - } - - @ViewBuilder - private var personView: some View { - ZStack { - Group { - if colorScheme == .light { - Color.secondarySystemFill - } else { - Color.tertiarySystemBackground - } - } - .posterShadow() - - RelativeSystemImageView(systemName: "person.fill", ratio: 0.5) - .foregroundStyle(.secondary) - } - .clipShape(.circle) - .aspectRatio(1, contentMode: .fill) - } - - @ViewBuilder - private var rowContent: some View { - HStack { - - VStack(alignment: .leading, spacing: 5) { - Text(user.username) - .font(.title3) - .fontWeight(.semibold) - .foregroundStyle(labelForegroundStyle) - .lineLimit(2) - .multilineTextAlignment(.leading) - - if showServer { - Text(server.name) - .font(.footnote) - .foregroundColor(Color(UIColor.lightGray)) - } - } - - Spacer() - - ListRowCheckbox() - } - } - - var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { - UserProfileImage( - userID: user.id, - source: user.profileImageSource( - client: server.client, - maxWidth: 120 - ), - pipeline: .Swiftfin.local - ) - .frame(width: 80) - .padding(.vertical, 8) - } content: { - rowContent - } - .onSelect(perform: action) - .contextMenu { - Button(L10n.delete, role: .destructive) { - onDelete() - } - } - } - } -} diff --git a/jellypig iOS/Views/SelectUserView/SelectUserView.swift b/jellypig iOS/Views/SelectUserView/SelectUserView.swift deleted file mode 100644 index 47ff1d17..00000000 --- a/jellypig iOS/Views/SelectUserView/SelectUserView.swift +++ /dev/null @@ -1,617 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import JellyfinAPI -import LocalAuthentication -import SwiftUI - -// TODO: authentication view during device authentication -// - could use provided UI, but is iOS 16+ -// - could just ignore for iOS 15, or basic view -// TODO: user ordering -// - name -// - last signed in date -// TODO: between the server selection menu and delete toolbar, -// figure out a way to make the grid/list and splash screen -// not jump when size is changed -// TODO: fix splash screen pulsing -// - should have used successful image source binding on ImageView? - -struct SelectUserView: View { - - typealias UserItem = (user: UserState, server: ServerState) - - // MARK: - Defaults - - @Default(.selectUserUseSplashscreen) - private var selectUserUseSplashscreen - @Default(.selectUserAllServersSplashscreen) - private var selectUserAllServersSplashscreen - @Default(.selectUserServerSelection) - private var serverSelection - @Default(.selectUserDisplayType) - private var userListDisplayType - - // MARK: - State & Environment Objects - - @EnvironmentObject - private var router: SelectUserCoordinator.Router - - @State - private var isEditingUsers: Bool = false - @State - private var pin: String = "" - @State - private var selectedUsers: Set = [] - - // MARK: - Dialog States - - @State - private var isPresentingConfirmDeleteUsers = false - @State - private var isPresentingLocalPin: Bool = false - - // MARK: - Error State - - @State - private var error: Error? = nil - - @StateObject - private var viewModel = SelectUserViewModel() - - private var selectedServer: ServerState? { - serverSelection.server(from: viewModel.servers.keys) - } - - private var splashScreenImageSources: [ImageSource] { - switch (serverSelection, selectUserAllServersSplashscreen) { - case (.all, .all): - return viewModel - .servers - .keys - .shuffled() - .map(\.splashScreenImageSource) - - // need to evaluate server with id selection first - case let (.server(id), _), let (.all, .server(id)): - guard let server = viewModel - .servers - .keys - .first(where: { $0.id == id }) else { return [] } - - return [server.splashScreenImageSource] - } - } - - private var userItems: [UserItem] { - switch serverSelection { - case .all: - return viewModel.servers - .map { server, users in - users.map { (server: server, user: $0) } - } - .flatMap { $0 } - .sorted(using: \.user.username) - .reversed() - .map { UserItem(user: $0.user, server: $0.server) } - case let .server(id: id): - guard let server = viewModel.servers.keys.first(where: { server in server.id == id }) else { - return [] - } - - return viewModel.servers[server]! - .sorted(using: \.username) - .map { UserItem(user: $0, server: server) } - } - } - - private func addUserSelected(server: ServerState) { - UIDevice.impact(.light) - router.route(to: \.userSignIn, server) - } - - private func delete(user: UserState) { - selectedUsers.insert(user) - isPresentingConfirmDeleteUsers = true - } - - // MARK: - Select User(s) - - private func select(user: UserState, needsPin: Bool = true) { - Task { @MainActor in - selectedUsers.insert(user) - - switch user.accessPolicy { - case .requireDeviceAuthentication: - try await performDeviceAuthentication(reason: L10n.userRequiresDeviceAuthentication(user.username)) - case .requirePin: - if needsPin { - isPresentingLocalPin = true - return - } - case .none: () - } - - viewModel.send(.signIn(user, pin: pin)) - } - } - - // MARK: - Perform Device Authentication - - // error logging/presentation is handled within here, just - // use try+thrown error in local Task for early return - private func performDeviceAuthentication(reason: String) async throws { - let context = LAContext() - var policyError: NSError? - - guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) else { - viewModel.logger.critical("\(policyError!.localizedDescription)") - - await MainActor.run { - self - .error = - JellyfinAPIError(L10n.unableToPerformDeviceAuthFaceID) - } - - throw JellyfinAPIError(L10n.deviceAuthFailed) - } - - do { - try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) - } catch { - viewModel.logger.critical("\(error.localizedDescription)") - - await MainActor.run { - self.error = JellyfinAPIError(L10n.unableToPerformDeviceAuth) - } - - throw JellyfinAPIError(L10n.deviceAuthFailed) - } - } - - // MARK: - Advanced Menu - - @ViewBuilder - private var advancedMenu: some View { - Menu(L10n.advanced, systemImage: "gearshape.fill") { - - Section { - - if userItems.isNotEmpty { - ConditionalMenu( - tracking: selectedServer, - action: addUserSelected - ) { - Section(L10n.servers) { - let servers = viewModel.servers.keys - - ForEach(servers) { server in - Button { - addUserSelected(server: server) - } label: { - Text(server.name) - Text(server.currentURL.absoluteString) - } - } - } - } label: { - Label(L10n.addUser, systemImage: "plus") - } - - Toggle( - L10n.editUsers, - systemImage: "person.crop.circle", - isOn: $isEditingUsers - ) - } - } - - if viewModel.servers.isNotEmpty { - Picker(selection: $userListDisplayType) { - ForEach(LibraryDisplayType.allCases, id: \.hashValue) { - Label($0.displayTitle, systemImage: $0.systemImage) - .tag($0) - } - } label: { - Text(L10n.layout) - Text(userListDisplayType.displayTitle) - Image(systemName: userListDisplayType.systemImage) - } - .pickerStyle(.menu) - } - - Section { - Button(L10n.advanced, systemImage: "gearshape.fill") { - router.route(to: \.advancedSettings) - } - } - } - } - - @ViewBuilder - private var addUserGridButtonView: some View { - AddUserGridButton( - selectedServer: selectedServer, - servers: viewModel.servers.keys, - action: addUserSelected - ) - } - - @ViewBuilder - private func userGridItemView(for item: UserItem) -> some View { - let user = item.user - let server = item.server - - UserGridButton( - user: user, - server: server, - showServer: serverSelection == .all - ) { - if isEditingUsers { - selectedUsers.toggle(value: user) - } else { - select(user: user) - } - } onDelete: { - delete(user: user) - } - .environment(\.isSelected, selectedUsers.contains(user)) - } - - // MARK: - iPad Grid Content View - - @ViewBuilder - private var padGridContentView: some View { - if userItems.isEmpty { - CenteredLazyVGrid( - data: [0], - id: \.self, - minimum: 150, - maximum: 300, - spacing: EdgeInsets.edgePadding - ) { _ in - addUserGridButtonView - } - } else { - CenteredLazyVGrid( - data: userItems, - id: \.user.id, - minimum: 150, - maximum: 300, - spacing: EdgeInsets.edgePadding, - content: userGridItemView - ) - } - } - - // MARK: - iPhone Grid Content View - - @ViewBuilder - private var phoneGridContentView: some View { - if userItems.isEmpty { - CenteredLazyVGrid( - data: [0], - id: \.self, - columns: 2 - ) { _ in - addUserGridButtonView - } - } else { - CenteredLazyVGrid( - data: userItems, - id: \.user.id, - columns: 2, - spacing: EdgeInsets.edgePadding, - content: userGridItemView - ) - .edgePadding() - } - } - - // MARK: - List Content View - - @ViewBuilder - private var listContentView: some View { - List { - let userItems = self.userItems - - if userItems.isEmpty { - AddUserListRow( - selectedServer: selectedServer, - servers: viewModel.servers.keys, - action: addUserSelected - ) - .listRowBackground(EmptyView()) - .listRowInsets(.zero) - .listRowSeparator(.hidden) - } - - ForEach(userItems, id: \.user.id) { item in - let user = item.user - let server = item.server - - UserListRow( - user: user, - server: server, - showServer: serverSelection == .all - ) { - if isEditingUsers { - selectedUsers.toggle(value: user) - } else { - select(user: user) - } - } onDelete: { - delete(user: user) - } - .environment(\.isSelected, selectedUsers.contains(user)) - .swipeActions { - if !isEditingUsers { - Button( - L10n.delete, - systemImage: "trash" - ) { - delete(user: user) - } - .tint(.red) - } - } - } - .listRowBackground(EmptyView()) - .listRowInsets(.zero) - .listRowSeparator(.hidden) - } - .listStyle(.plain) - } - - // MARK: - User View - - @ViewBuilder - private var contentView: some View { - VStack(spacing: 0) { - ZStack { - switch userListDisplayType { - case .grid: - Group { - if UIDevice.isPhone { - phoneGridContentView - } else { - padGridContentView - } - } - .scrollIfLargerThanContainer(padding: 100) - case .list: - listContentView - } - } - .animation(.linear(duration: 0.1), value: userListDisplayType) - .environment(\.isEditing, isEditingUsers) - .frame(maxHeight: .infinity) - .mask { - VStack(spacing: 0) { - Color.white - - LinearGradient( - stops: [ - .init(color: .white, location: 0), - .init(color: .clear, location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 30) - } - } - - if !isEditingUsers { - ServerSelectionMenu( - selection: $serverSelection, - selectedServer: selectedServer, - servers: viewModel.servers.keys - ) - .edgePadding([.bottom, .horizontal]) - } - } - .background { - if selectUserUseSplashscreen, splashScreenImageSources.isNotEmpty { - ZStack { - Color.clear - - ImageView(splashScreenImageSources) - .pipeline(.Swiftfin.local) - .aspectRatio(contentMode: .fill) - .transition(.opacity.animation(.linear(duration: 0.1))) - .id(splashScreenImageSources) - - Color.black - .opacity(0.9) - } - .ignoresSafeArea() - } - } - } - - // MARK: - Connect to Server View - - @ViewBuilder - private var connectToServerView: some View { - VStack(spacing: 10) { - L10n.connectToJellyfinServerStart.text - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) - - PrimaryButton(title: L10n.connect) - .onSelect { - router.route(to: \.connectToServer) - } - .frame(maxWidth: 300) - } - } - - // MARK: - Body - - var body: some View { - ZStack { - if viewModel.servers.isEmpty { - connectToServerView - } else { - contentView - } - } - .ignoresSafeArea(.keyboard, edges: .bottom) - .navigationTitle(L10n.users) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - Image(uiImage: .jellyfinBlobBlue) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 30) - } - - ToolbarItem(placement: .topBarLeading) { - if isEditingUsers { - if selectedUsers.count == userItems.count { - Button(L10n.removeAll) { - selectedUsers.removeAll() - } - .buttonStyle(.toolbarPill) - } else { - Button(L10n.selectAll) { - selectedUsers.insert(contentsOf: userItems.map(\.user)) - } - .buttonStyle(.toolbarPill) - } - } - } - - ToolbarItemGroup(placement: .topBarTrailing) { - if isEditingUsers { - Button(isEditingUsers ? L10n.cancel : L10n.edit) { - isEditingUsers.toggle() - - UIDevice.impact(.light) - - if !isEditingUsers { - selectedUsers.removeAll() - } - } - .buttonStyle(.toolbarPill) - } else { - advancedMenu - } - } - - ToolbarItem(placement: .bottomBar) { - if isEditingUsers { - Button(L10n.delete) { - isPresentingConfirmDeleteUsers = true - } - .buttonStyle(.toolbarPill(.red)) - .disabled(selectedUsers.isEmpty) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - .onAppear { - viewModel.send(.getServers) - } - .onChange(of: isEditingUsers) { newValue in - guard !newValue else { return } - selectedUsers.removeAll() - } - .onChange(of: isPresentingConfirmDeleteUsers) { newValue in - guard !newValue else { return } - isEditingUsers = false - selectedUsers.removeAll() - } - .onChange(of: isPresentingLocalPin) { newValue in - guard newValue else { return } - pin = "" - } - .onChange(of: viewModel.servers.keys) { newValue in - if case let SelectUserServerSelection.server(id: id) = serverSelection, - !newValue.contains(where: { $0.id == id }) - { - if newValue.count == 1, let firstServer = newValue.first { - let newSelection = SelectUserServerSelection.server(id: firstServer.id) - serverSelection = newSelection - selectUserAllServersSplashscreen = newSelection - } else { - serverSelection = .all - selectUserAllServersSplashscreen = .all - } - } - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - self.error = eventError - case let .signedIn(user): - UIDevice.feedback(.success) - - Defaults[.lastSignedInUserID] = .signedIn(userID: user.id) - Container.shared.currentUserSession.reset() - Notifications[.didSignIn].post() - } - } - .onNotification(.didConnectToServer) { server in - viewModel.send(.getServers) - serverSelection = .server(id: server.id) - } - .onNotification(.didChangeCurrentServerURL) { _ in - viewModel.send(.getServers) - } - .onNotification(.didDeleteServer) { _ in - viewModel.send(.getServers) - } - .alert( - L10n.deleteUser, - isPresented: $isPresentingConfirmDeleteUsers - ) { - Button(L10n.delete, role: .destructive) { - viewModel.send(.deleteUsers(selectedUsers)) - } - } message: { - if selectedUsers.count == 1, let first = selectedUsers.first { - Text(L10n.deleteUserSingleConfirmation(first.username)) - } else { - Text(L10n.deleteUserMultipleConfirmation(selectedUsers.count)) - } - } - .alert(L10n.signIn, isPresented: $isPresentingLocalPin) { - - TextField(L10n.pin, text: $pin) - .keyboardType(.numberPad) - - // bug in SwiftUI: having .disabled will dismiss - // alert but not call the closure (for length) - Button(L10n.signIn) { - guard let user = selectedUsers.first else { - assertionFailure("User not selected") - return - } - - select(user: user, needsPin: false) - } - - Button(L10n.cancel, role: .cancel) {} - } message: { - if let user = selectedUsers.first, user.pinHint.isNotEmpty { - Text(user.pinHint) - } else { - let username = selectedUsers.first?.username ?? .emptyDash - - Text(L10n.enterPinForUser(username)) - } - } - .errorMessage($error) - } -} diff --git a/jellypig iOS/Views/ServerCheckView.swift b/jellypig iOS/Views/ServerCheckView.swift deleted file mode 100644 index 3ece182f..00000000 --- a/jellypig iOS/Views/ServerCheckView.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct ServerCheckView: View { - - @EnvironmentObject - private var router: MainCoordinator.Router - - @StateObject - private var viewModel = ServerCheckViewModel() - - @ViewBuilder - private func errorView(_ error: E) -> some View { - VStack(spacing: 10) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 72)) - .foregroundColor(Color.red) - - Text(viewModel.userSession.server.name) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - - Text(error.localizedDescription) - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) - - PrimaryButton(title: L10n.retry) - .onSelect { - viewModel.send(.checkServer) - } - .frame(maxWidth: 300) - .frame(height: 50) - } - } - - var body: some View { - ZStack { - switch viewModel.state { - case .initial, .connecting, .connected: - ZStack { - Color.clear - - ProgressView() - } - case let .error(error): - errorView(error) - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .onFirstAppear { - viewModel.send(.checkServer) - } - .onReceive(viewModel.$state) { newState in - if newState == .connected { - withAnimation(.linear(duration: 0.1)) { - let _ = router.root(\.mainTab) - } - } - } - .topBarTrailing { - - SettingsBarButton( - server: viewModel.userSession.server, - user: viewModel.userSession.user - ) { - router.route(to: \.settings) - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift b/jellypig iOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift deleted file mode 100644 index 0060044a..00000000 --- a/jellypig iOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Foundation -import SwiftUI - -extension CustomDeviceProfileSettingsView { - - struct CustomProfileButton: View { - - let profile: CustomDeviceProfile - let onSelect: () -> Void - - @ViewBuilder - private func profileDetailsView(title: String, detail: String) -> some View { - VStack(alignment: .leading) { - Text(title) - .fontWeight(.semibold) - .foregroundStyle(.primary) - - Text(detail) - .foregroundColor(.secondary) - } - .font(.subheadline) - } - - var body: some View { - Button(action: onSelect) { - HStack { - VStack(alignment: .leading, spacing: 8) { - profileDetailsView( - title: L10n.audio, - detail: profile.audio.map(\.displayTitle).joined(separator: ", ") - ) - - profileDetailsView( - title: L10n.video, - detail: profile.video.map(\.displayTitle).joined(separator: ", ") - ) - - profileDetailsView( - title: L10n.containers, - detail: profile.container.map(\.displayTitle).joined(separator: ", ") - ) - - profileDetailsView( - title: L10n.useAsTranscodingProfile, - detail: profile.useAsTranscodingProfile ? L10n.yes : L10n.no - ) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.body.weight(.regular)) - .foregroundColor(.secondary) - } - } - .foregroundStyle(.primary) - } - } -} diff --git a/jellypig iOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift b/jellypig iOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift deleted file mode 100644 index e19de04b..00000000 --- a/jellypig iOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension CustomDeviceProfileSettingsView { - - struct EditCustomDeviceProfileView: View { - - @Default(.accentColor) - private var accentColor - - @StoredValue(.User.customDeviceProfiles) - private var customDeviceProfiles - - @EnvironmentObject - private var router: EditCustomDeviceProfileCoordinator.Router - - @State - private var isPresentingNotSaved = false - @State - private var profile: CustomDeviceProfile - - private let createProfile: Bool - private let source: Binding? - - private var isValid: Bool { - profile.audio.isNotEmpty && - profile.video.isNotEmpty && - profile.container.isNotEmpty - } - - init(profile: Binding?) { - - createProfile = profile == nil - - if let profile { - self._profile = State(initialValue: profile.wrappedValue) - self.source = profile - } else { - self._profile = State(initialValue: .init(type: .video)) - self.source = nil - } - } - - @ViewBuilder - private func codecSection( - title: String, - content: String, - onSelect: @escaping () -> Void - ) -> some View { - Button(action: onSelect) { - HStack { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .fontWeight(.semibold) - .foregroundStyle(.primary) - - if content.isEmpty { - Label(L10n.none, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - .foregroundColor(.secondary) - } else { - Text(content) - .foregroundColor(.secondary) - } - } - .font(.subheadline) - - Spacer() - - Image(systemName: "chevron.right") - .font(.body.weight(.regular)) - .foregroundColor(.secondary) - } - } - .foregroundStyle(.primary) - } - - var body: some View { - Form { - Toggle(L10n.useAsTranscodingProfile, isOn: $profile.useAsTranscodingProfile) - - Section { - codecSection( - title: L10n.audio, - content: profile.audio.map(\.displayTitle).joined(separator: ", ") - ) { - router.route(to: \.customDeviceAudioEditor, $profile.audio) - } - - codecSection( - title: L10n.video, - content: profile.video.map(\.displayTitle).joined(separator: ", ") - ) { - router.route(to: \.customDeviceVideoEditor, $profile.video) - } - - codecSection( - title: L10n.containers, - content: profile.container.map(\.displayTitle).joined(separator: ", ") - ) { - router.route(to: \.customDeviceContainerEditor, $profile.container) - } - } footer: { - if !isValid { - Label(L10n.missingCodecValues, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } - } - } - .interactiveDismissDisabled(true) - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden() - .navigationBarCloseButton { - isPresentingNotSaved = true - } - .navigationTitle(L10n.customProfile) - .topBarTrailing { - Button(L10n.save) { - if createProfile { - customDeviceProfiles.append(profile) - } else { - source?.wrappedValue = profile - } - - UIDevice.impact(.light) - router.dismissCoordinator() - } - .buttonStyle(.toolbarPill) - .disabled(!isValid) - } - .alert(L10n.profileNotSaved, isPresented: $isPresentingNotSaved) { - Button(L10n.close, role: .destructive) { - router.dismissCoordinator() - } - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift b/jellypig iOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift deleted file mode 100644 index 861c2cf2..00000000 --- a/jellypig iOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import SwiftUI - -struct CustomDeviceProfileSettingsView: View { - - @Default(.VideoPlayer.Playback.customDeviceProfileAction) - private var customDeviceProfileAction - - @StoredValue(.User.customDeviceProfiles) - private var customProfiles: [CustomDeviceProfile] - - @EnvironmentObject - private var router: SettingsCoordinator.Router - - private var isValid: Bool { - customDeviceProfileAction == .add || - customProfiles.isNotEmpty - } - - private func removeProfile(at offsets: IndexSet) { - customProfiles.remove(atOffsets: offsets) - } - - var body: some View { - List { - Section { - CaseIterablePicker( - L10n.behavior, - selection: $customDeviceProfileAction - ) - } footer: { - VStack(spacing: 8) { - switch customDeviceProfileAction { - case .add: - L10n.customDeviceProfileAdd.text - case .replace: - L10n.customDeviceProfileReplace.text - } - - if !isValid { - Label(L10n.noDeviceProfileWarning, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - } - } - } - - Section(L10n.profiles) { - - if customProfiles.isEmpty { - Button(L10n.add) { - router.route(to: \.createCustomDeviceProfile) - } - } - - ForEach($customProfiles, id: \.self) { $profile in - CustomProfileButton(profile: profile) { - router.route(to: \.editCustomDeviceProfile, $profile) - } - } - .onDelete(perform: removeProfile) - } - } - .navigationTitle(L10n.profiles) - .topBarTrailing { - if customProfiles.isNotEmpty { - Button(L10n.add) { - UIDevice.impact(.light) - router.route(to: \.createCustomDeviceProfile) - } - .buttonStyle(.toolbarPill) - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift b/jellypig iOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift deleted file mode 100644 index 01abdeb9..00000000 --- a/jellypig iOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension CustomizeViewsSettings { - - struct HomeSection: View { - - @Default(.Customization.Home.showRecentlyAdded) - private var showRecentlyAdded - @Default(.Customization.Home.maxNextUp) - private var maxNextUp - @Default(.Customization.Home.resumeNextUp) - private var resumeNextUp - - var body: some View { - Section(L10n.home) { - - Toggle(L10n.showRecentlyAdded, isOn: $showRecentlyAdded) - - Toggle(L10n.nextUpRewatch, isOn: $resumeNextUp) - - ChevronButton( - L10n.nextUpDays, - subtitle: { - if maxNextUp > 0 { - return Text(maxNextUp, format: .interval(style: .narrow, fields: [.day])) - } else { - return Text(L10n.disabled) - } - }(), - description: L10n.nextUpDaysDescription - ) { - TextField( - L10n.days, - value: $maxNextUp, - format: .dayInterval(range: 0 ... 1000) - ) - .keyboardType(.numberPad) - } - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift b/jellypig iOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift deleted file mode 100644 index 5b081334..00000000 --- a/jellypig iOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/ItemSection.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import SwiftUI - -extension CustomizeViewsSettings { - - struct ItemSection: View { - - @Injected(\.currentUserSession) - private var userSession - - @EnvironmentObject - private var router: SettingsCoordinator.Router - - @StoredValue(.User.itemViewAttributes) - private var itemViewAttributes - @StoredValue(.User.enabledTrailers) - private var enabledTrailers - - @StoredValue(.User.enableItemEditing) - private var enableItemEditing - @StoredValue(.User.enableItemDeletion) - private var enableItemDeletion - @StoredValue(.User.enableCollectionManagement) - private var enableCollectionManagement - - var body: some View { - Section(L10n.items) { - - ChevronButton(L10n.mediaAttributes) { - router.route(to: \.itemViewAttributes, $itemViewAttributes) - } - - CaseIterablePicker( - L10n.enabledTrailers, - selection: $enabledTrailers - ) - - /// Enabled Collection Management for collection managers - if userSession?.user.permissions.items.canManageCollections == true { - Toggle(L10n.editCollections, isOn: $enableCollectionManagement) - } - /// Enabled Media Management when there are media elements that can be managed - if userSession?.user.permissions.items.canEditMetadata == true || - userSession?.user.permissions.items.canManageLyrics == true || - userSession?.user.permissions.items.canManageSubtitles == true - { - Toggle(L10n.editMedia, isOn: $enableItemEditing) - } - /// Enabled Media Deletion for valid deletion users - if userSession?.user.permissions.items.canDelete == true { - Toggle(L10n.deleteMedia, isOn: $enableItemDeletion) - } - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/jellypig iOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift deleted file mode 100644 index 988382d1..00000000 --- a/jellypig iOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// TODO: will be entirely re-organized - -struct CustomizeViewsSettings: View { - - @Default(.Customization.itemViewType) - private var itemViewType - @Default(.Customization.CinematicItemViewType.usePrimaryImage) - private var cinematicItemViewTypeUsePrimaryImage - - @Default(.Customization.shouldShowMissingSeasons) - private var shouldShowMissingSeasons - @Default(.Customization.shouldShowMissingEpisodes) - private var shouldShowMissingEpisodes - - @Default(.Customization.Library.letterPickerEnabled) - var letterPickerEnabled - @Default(.Customization.Library.letterPickerOrientation) - var letterPickerOrientation - @Default(.Customization.Library.enabledDrawerFilters) - private var libraryEnabledDrawerFilters - @Default(.Customization.Search.enabledDrawerFilters) - private var searchEnabledDrawerFilters - - @Default(.Customization.showPosterLabels) - private var showPosterLabels - @Default(.Customization.nextUpPosterType) - private var nextUpPosterType - @Default(.Customization.recentlyAddedPosterType) - private var showRecentlyAdded - @Default(.Customization.latestInLibraryPosterType) - private var latestInLibraryPosterType - @Default(.Customization.similarPosterType) - private var similarPosterType - @Default(.Customization.searchPosterType) - private var searchPosterType - @Default(.Customization.Library.displayType) - private var libraryDisplayType - @Default(.Customization.Library.posterType) - private var libraryPosterType - @Default(.Customization.Library.listColumnCount) - private var listColumnCount - - @Default(.Customization.Library.rememberLayout) - private var rememberLibraryLayout - @Default(.Customization.Library.rememberSort) - private var rememberLibrarySort - - @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) - private var useSeriesLandscapeBackdrop - - @Default(.Customization.Library.showFavorites) - private var showFavorites - @Default(.Customization.Library.randomImage) - private var libraryRandomImage - - @EnvironmentObject - private var router: SettingsCoordinator.Router - - var body: some View { - List { - - if UIDevice.isPhone { - Section { - CaseIterablePicker(L10n.items, selection: $itemViewType) - } - - if itemViewType == .cinematic { - Section { - Toggle(L10n.usePrimaryImage, isOn: $cinematicItemViewTypeUsePrimaryImage) - } footer: { - L10n.usePrimaryImageDescription.text - } - } - } - - Section { - - Toggle(L10n.favorites, isOn: $showFavorites) - Toggle(L10n.randomImage, isOn: $libraryRandomImage) - - } header: { - L10n.library.text - } - - Section { - - Toggle(L10n.letterPicker, isOn: $letterPickerEnabled) - - if letterPickerEnabled { - CaseIterablePicker( - L10n.orientation, - selection: $letterPickerOrientation - ) - } - - ChevronButton(L10n.library) { - router.route(to: \.itemFilterDrawerSelector, $libraryEnabledDrawerFilters) - } - - ChevronButton(L10n.search) { - router.route(to: \.itemFilterDrawerSelector, $searchEnabledDrawerFilters) - } - - } header: { - L10n.filters.text - } - - Section { - Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) - Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) - } header: { - L10n.missingItems.text - } - - Section(L10n.posters) { - - ChevronButton(L10n.indicators) { - router.route(to: \.indicatorSettings) - } - - Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) - - CaseIterablePicker(L10n.next, selection: $nextUpPosterType) - - CaseIterablePicker(L10n.latestWithString(L10n.library), selection: $latestInLibraryPosterType) - - CaseIterablePicker(L10n.recommended, selection: $similarPosterType) - - CaseIterablePicker(L10n.search, selection: $searchPosterType) - } - - Section(L10n.libraries) { - CaseIterablePicker(L10n.library, selection: $libraryDisplayType) - - CaseIterablePicker(L10n.posters, selection: $libraryPosterType) - - if libraryDisplayType == .list, UIDevice.isPad { - BasicStepper( - title: L10n.columns, - value: $listColumnCount, - range: 1 ... 4, - step: 1 - ) - } - } - - ItemSection() - - HomeSection() - - Section { - Toggle(L10n.rememberLayout, isOn: $rememberLibraryLayout) - } footer: { - Text(L10n.rememberLayoutFooter) - } - - Section { - Toggle(L10n.rememberSorting, isOn: $rememberLibrarySort) - } footer: { - Text(L10n.rememberSortingFooter) - } - - Section { - Toggle(L10n.seriesBackdrop, isOn: $useSeriesLandscapeBackdrop) - } header: { - // TODO: think of a better name - L10n.episodeLandscapePoster.text - } - } - .navigationTitle(L10n.customize) - } -} diff --git a/jellypig iOS/Views/SettingsView/DebugSettingsView.swift b/jellypig iOS/Views/SettingsView/DebugSettingsView.swift deleted file mode 100644 index ddf2c131..00000000 --- a/jellypig iOS/Views/SettingsView/DebugSettingsView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// NOTE: All settings *MUST* be surrounded by DEBUG compiler conditional as usage site - -#if DEBUG -struct DebugSettingsView: View { - - @Default(.sendProgressReports) - private var sendProgressReports - - var body: some View { - Form { - - Toggle("Send Progress Reports", isOn: $sendProgressReports) - } - .navigationTitle("Debug") - } -} -#endif diff --git a/jellypig iOS/Views/SettingsView/ExperimentalSettingsView.swift b/jellypig iOS/Views/SettingsView/ExperimentalSettingsView.swift deleted file mode 100644 index 9101f4d3..00000000 --- a/jellypig iOS/Views/SettingsView/ExperimentalSettingsView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// Note: Used for experimental settings that may be removed or implemented -// officially. Keep for future settings. - -struct ExperimentalSettingsView: View { - - var body: some View { - Form {} - .navigationTitle(L10n.experimental) - } -} diff --git a/jellypig iOS/Views/SettingsView/GestureSettingsView.swift b/jellypig iOS/Views/SettingsView/GestureSettingsView.swift deleted file mode 100644 index 92954185..00000000 --- a/jellypig iOS/Views/SettingsView/GestureSettingsView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// TODO: organize into a better structure -// TODO: add footer descriptions to each explaining the -// the gesture + why horizontal pan/swipe caveat -// TODO: add page describing each action? - -struct GestureSettingsView: View { - - @Default(.VideoPlayer.Gesture.horizontalPanGesture) - private var horizontalPanGesture - @Default(.VideoPlayer.Gesture.horizontalSwipeGesture) - private var horizontalSwipeGesture - @Default(.VideoPlayer.Gesture.longPressGesture) - private var longPressGesture - @Default(.VideoPlayer.Gesture.multiTapGesture) - private var multiTapGesture - @Default(.VideoPlayer.Gesture.doubleTouchGesture) - private var doubleTouchGesture - @Default(.VideoPlayer.Gesture.pinchGesture) - private var pinchGesture - @Default(.VideoPlayer.Gesture.verticalPanGestureLeft) - private var verticalPanGestureLeft - @Default(.VideoPlayer.Gesture.verticalPanGestureRight) - private var verticalPanGestureRight - - var body: some View { - Form { - - Section { - - CaseIterablePicker(L10n.horizontalPan, selection: $horizontalPanGesture) - .disabled(horizontalSwipeGesture != .none && horizontalPanGesture == .none) - - CaseIterablePicker(L10n.horizontalSwipe, selection: $horizontalSwipeGesture) - .disabled(horizontalPanGesture != .none && horizontalSwipeGesture == .none) - - CaseIterablePicker(L10n.longPress, selection: $longPressGesture) - - CaseIterablePicker(L10n.multiTap, selection: $multiTapGesture) - - CaseIterablePicker(L10n.doubleTouch, selection: $doubleTouchGesture) - - CaseIterablePicker(L10n.pinch, selection: $pinchGesture) - - CaseIterablePicker(L10n.leftVerticalPan, selection: $verticalPanGestureLeft) - - CaseIterablePicker(L10n.rightVerticalPan, selection: $verticalPanGestureRight) - } - } - .navigationTitle(L10n.gestures) - } -} diff --git a/jellypig iOS/Views/SettingsView/IndicatorSettingsView.swift b/jellypig iOS/Views/SettingsView/IndicatorSettingsView.swift deleted file mode 100644 index 5cbea284..00000000 --- a/jellypig iOS/Views/SettingsView/IndicatorSettingsView.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -// TODO: show a sample poster to model indicators - -struct IndicatorSettingsView: View { - - @Default(.Customization.Indicators.showFavorited) - private var showFavorited - @Default(.Customization.Indicators.showProgress) - private var showProgress - @Default(.Customization.Indicators.showUnplayed) - private var showUnplayed - @Default(.Customization.Indicators.showPlayed) - private var showPlayed - - var body: some View { - Form { - Section { - - Toggle(L10n.favorited, isOn: $showFavorited) - - Toggle(L10n.progress, isOn: $showProgress) - - Toggle(L10n.unplayed, isOn: $showUnplayed) - - Toggle(L10n.played, isOn: $showPlayed) - } - } - .navigationTitle(L10n.indicators) - } -} diff --git a/jellypig iOS/Views/SettingsView/NativeVideoPlayerSettingsView.swift b/jellypig iOS/Views/SettingsView/NativeVideoPlayerSettingsView.swift deleted file mode 100644 index 12ff5e56..00000000 --- a/jellypig iOS/Views/SettingsView/NativeVideoPlayerSettingsView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct NativeVideoPlayerSettingsView: View { - - @Default(.VideoPlayer.resumeOffset) - private var resumeOffset - - var body: some View { - Form { - - Section { - - BasicStepper( - title: L10n.resumeOffset, - value: $resumeOffset, - range: 0 ... 30, - step: 1 - ) - .valueFormatter { - $0.secondLabel - } - } footer: { - Text(L10n.resumeOffsetDescription) - } - } - .navigationTitle(L10n.nativePlayer) - } -} diff --git a/jellypig iOS/Views/SettingsView/PlaybackQualitySettingsView.swift b/jellypig iOS/Views/SettingsView/PlaybackQualitySettingsView.swift deleted file mode 100644 index 5ffcbc8b..00000000 --- a/jellypig iOS/Views/SettingsView/PlaybackQualitySettingsView.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct PlaybackQualitySettingsView: View { - - @Default(.VideoPlayer.Playback.appMaximumBitrate) - private var appMaximumBitrate - @Default(.VideoPlayer.Playback.appMaximumBitrateTest) - private var appMaximumBitrateTest - @Default(.VideoPlayer.Playback.compatibilityMode) - private var compatibilityMode - - @EnvironmentObject - private var router: SettingsCoordinator.Router - - var body: some View { - Form { - Section { - CaseIterablePicker( - L10n.maximumBitrate, - selection: $appMaximumBitrate - ) - } header: { - L10n.bitrateDefault.text - } footer: { - VStack(alignment: .leading) { - Text(L10n.bitrateDefaultDescription) - LearnMoreButton(L10n.bitrateDefault) { - TextPair( - title: L10n.auto, - subtitle: L10n.birateAutoDescription - ) - TextPair( - title: L10n.bitrateMax, - subtitle: L10n.bitrateMaxDescription(PlaybackBitrate.max.rawValue.formatted(.bitRate)) - ) - } - } - } - .animation(.none, value: appMaximumBitrate) - - if appMaximumBitrate == .auto { - Section { - CaseIterablePicker( - L10n.testSize, - selection: $appMaximumBitrateTest - ) - } header: { - L10n.bitrateTest.text - } footer: { - VStack(alignment: .leading) { - L10n.bitrateTestDisclaimer.text - } - } - } - - Section { - CaseIterablePicker( - L10n.compatibility, - selection: $compatibilityMode - ) - .animation(.none, value: compatibilityMode) - - if compatibilityMode == .custom { - ChevronButton(L10n.profiles) { - router.route(to: \.customDeviceProfileSettings) - } - } - } header: { - Text(L10n.deviceProfile) - } footer: { - VStack(alignment: .leading) { - Text(L10n.deviceProfileDescription) - LearnMoreButton(L10n.deviceProfile) { - TextPair( - title: L10n.auto, - subtitle: L10n.autoDescription - ) - TextPair( - title: L10n.compatible, - subtitle: L10n.compatibleDescription - ) - TextPair( - title: L10n.direct, - subtitle: L10n.directDescription - ) - TextPair( - title: L10n.custom, - subtitle: L10n.customDescription - ) - } - } - } - } - .animation(.linear, value: appMaximumBitrate) - .animation(.linear, value: compatibilityMode) - .navigationTitle(L10n.playbackQuality) - } -} diff --git a/jellypig iOS/Views/SettingsView/SettingsView/SettingsView.swift b/jellypig iOS/Views/SettingsView/SettingsView/SettingsView.swift deleted file mode 100644 index 1e4ed3f4..00000000 --- a/jellypig iOS/Views/SettingsView/SettingsView/SettingsView.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import Stinsen -import SwiftUI - -struct SettingsView: View { - - @Default(.userAccentColor) - private var accentColor - @Default(.userAppearance) - private var appearance - @Default(.VideoPlayer.videoPlayerType) - private var videoPlayerType - - @EnvironmentObject - private var router: SettingsCoordinator.Router - - @StateObject - private var viewModel = SettingsViewModel() - - var body: some View { - Form { - - Section { - - UserProfileRow(user: viewModel.userSession.user.data) { - router.route(to: \.userProfile, viewModel) - } - - ChevronButton( - L10n.server, - subtitle: viewModel.userSession.server.name - ) { - router.route(to: \.serverConnection, viewModel.userSession.server) - } - - if viewModel.userSession.user.permissions.isAdministrator { - ChevronButton(L10n.dashboard) { - router.route(to: \.adminDashboard) - } - } - } - - ListRowButton(L10n.switchUser) { - UIDevice.impact(.medium) - - router.dismissCoordinator { - viewModel.signOut() - } - } - .foregroundStyle(accentColor.overlayColor, accentColor) - - Section(L10n.videoPlayer) { - CaseIterablePicker( - L10n.videoPlayerType, - selection: $videoPlayerType - ) - - ChevronButton(L10n.nativePlayer) { - router.route(to: \.nativePlayerSettings) - } - - ChevronButton(L10n.videoPlayer) { - router.route(to: \.videoPlayerSettings) - } - - ChevronButton(L10n.playbackQuality) { - router.route(to: \.playbackQualitySettings) - } - } - - Section(L10n.accessibility) { - CaseIterablePicker(L10n.appearance, selection: $appearance) - - ChevronButton(L10n.customize) { - router.route(to: \.customizeViewsSettings) - } - - // Note: uncomment if there are current - // experimental settings - -// ChevronButton(L10n.experimental) -// .onSelect { -// router.route(to: \.experimentalSettings) -// } - } - - Section { - ColorPicker(L10n.accentColor, selection: $accentColor, supportsOpacity: false) - } footer: { - Text(L10n.viewsMayRequireRestart) - } - - ChevronButton(L10n.logs) { - router.route(to: \.log) - } - - #if DEBUG - - ChevronButton("Debug") { - router.route(to: \.debugSettings) - } - - #endif - } - .navigationTitle(L10n.settings) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismissCoordinator() - } - } -} diff --git a/jellypig iOS/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift b/jellypig iOS/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift deleted file mode 100644 index c82a7783..00000000 --- a/jellypig iOS/Views/SettingsView/UserProfileSettingsView/QuickConnectAuthorizeView.swift +++ /dev/null @@ -1,157 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Foundation -import JellyfinAPI -import SwiftUI - -struct QuickConnectAuthorizeView: View { - - // MARK: - Dismiss Environment - - @Environment(\.dismiss) - private var dismiss - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Focus Fields - - @FocusState - private var isCodeFocused: Bool - - // MARK: - State & Environment Objects - - @StateObject - private var viewModel: QuickConnectAuthorizeViewModel - - // MARK: - Quick Connect Variables - - @State - private var code: String = "" - - // MARK: - Dialog State - - @State - private var isPresentingSuccess: Bool = false - - // MARK: - Error State - - @State - private var error: Error? = nil - - // MARK: - Initialize - - init(user: UserDto) { - self._viewModel = StateObject(wrappedValue: QuickConnectAuthorizeViewModel(user: user)) - } - - // MARK: Display the User Being Authenticated - - @ViewBuilder - private var loginUserRow: some View { - HStack { - UserProfileImage( - userID: viewModel.user.id, - source: viewModel.user.profileImageSource( - client: viewModel.userSession.client, - maxWidth: 120 - ) - ) - .frame(width: 50, height: 50) - - Text(viewModel.user.name ?? L10n.unknown) - .fontWeight(.semibold) - .foregroundStyle(.primary) - - Spacer() - } - } - - // MARK: - Body - - var body: some View { - Form { - Section { - loginUserRow - } header: { - Text(L10n.user) - } footer: { - Text(L10n.quickConnectUserDisclaimer) - } - - Section { - TextField(L10n.quickConnectCode, text: $code) - .keyboardType(.numberPad) - .disabled(viewModel.state == .authorizing) - .focused($isCodeFocused) - } footer: { - Text(L10n.quickConnectCodeInstruction) - } - - if viewModel.state == .authorizing { - ListRowButton(L10n.cancel, role: .cancel) { - viewModel.send(.cancel) - isCodeFocused = true - } - } else { - ListRowButton(L10n.authorize) { - viewModel.send(.authorize(code: code)) - } - .disabled(code.count != 6 || viewModel.state == .authorizing) - .foregroundStyle( - accentColor.overlayColor, - accentColor - ) - .opacity(code.count != 6 ? 0.5 : 1) - } - } - .interactiveDismissDisabled(viewModel.state == .authorizing) - .navigationBarBackButtonHidden(viewModel.state == .authorizing) - .navigationTitle(L10n.quickConnect.text) - .onFirstAppear { - isCodeFocused = true - } - .onChange(of: code) { newValue in - code = String(newValue.prefix(6)) - } - .onReceive(viewModel.events) { event in - switch event { - case .authorized: - UIDevice.feedback(.success) - - isPresentingSuccess = true - case let .error(eventError): - UIDevice.feedback(.error) - - error = eventError - } - } - .topBarTrailing { - if viewModel.state == .authorizing { - ProgressView() - } - } - .alert( - L10n.quickConnect, - isPresented: $isPresentingSuccess - ) { - Button(L10n.dismiss, role: .cancel) { - dismiss() - } - } message: { - L10n.quickConnectSuccessMessage.text - } - .errorMessage($error) { - isCodeFocused = true - } - } -} diff --git a/jellypig iOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift b/jellypig iOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift deleted file mode 100644 index b341ab0f..00000000 --- a/jellypig iOS/Views/SettingsView/UserProfileSettingsView/UserLocalSecurityView.swift +++ /dev/null @@ -1,279 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Combine -import Defaults -import KeychainSwift -import LocalAuthentication -import SwiftUI - -// TODO: present toast when authentication successfully changed -// TODO: pop is just a workaround to get change published from usersession. -// find fix and don't pop when successfully changed -// TODO: could cleanup/refactor greatly - -struct UserLocalSecurityView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - State & Environment Objects - - @EnvironmentObject - private var router: SettingsCoordinator.Router - - @StateObject - private var viewModel = UserLocalSecurityViewModel() - - // MARK: - Local Security Variables - - @State - private var listSize: CGSize = .zero - @State - private var onPinCompletion: (() -> Void)? - @State - private var pin: String = "" - @State - private var pinHint: String = "" - @State - private var signInPolicy: UserAccessPolicy = .none - - // MARK: - Dialog States - - @State - private var isPresentingOldPinPrompt: Bool = false - @State - private var isPresentingNewPinPrompt: Bool = false - - // MARK: - Error State - - @State - private var error: Error? = nil - - // MARK: - Check Old Policy - - private func checkOldPolicy() { - do { - try viewModel.checkForOldPolicy() - } catch { - return - } - - checkNewPolicy() - } - - // MARK: - Check New Policy - - private func checkNewPolicy() { - do { - try viewModel.checkFor(newPolicy: signInPolicy) - } catch { - return - } - - viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: pinHint) - } - - // MARK: - Perform Device Authentication - - // error logging/presentation is handled within here, just - // use try+thrown error in local Task for early return - private func performDeviceAuthentication(reason: String) async throws { - let context = LAContext() - var policyError: NSError? - - guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) else { - viewModel.logger.critical("\(policyError!.localizedDescription)") - - await MainActor.run { - self - .error = JellyfinAPIError(L10n.unableToPerformDeviceAuthFaceID) - } - - throw JellyfinAPIError(L10n.deviceAuthFailed) - } - - do { - try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) - } catch { - viewModel.logger.critical("\(error.localizedDescription)") - - await MainActor.run { - self.error = JellyfinAPIError(L10n.unableToPerformDeviceAuth) - } - - throw JellyfinAPIError(L10n.deviceAuthFailed) - } - } - - // MARK: - Body - - var body: some View { - List { - - Section { - CaseIterablePicker(L10n.security, selection: $signInPolicy) - } footer: { - VStack(alignment: .leading, spacing: 10) { - Text(L10n.additionalSecurityAccessDescription) - - // frame necessary with bug within BulletedList - BulletedList { - - VStack(alignment: .leading, spacing: 5) { - Text(UserAccessPolicy.requireDeviceAuthentication.displayTitle) - .fontWeight(.semibold) - - Text(L10n.requireDeviceAuthDescription) - } - .padding(.bottom, 15) - - VStack(alignment: .leading, spacing: 5) { - Text(UserAccessPolicy.requirePin.displayTitle) - .fontWeight(.semibold) - - Text(L10n.requirePinDescription) - } - .padding(.bottom, 15) - - VStack(alignment: .leading, spacing: 5) { - Text(UserAccessPolicy.none.displayTitle) - .fontWeight(.semibold) - - Text(L10n.saveUserWithoutAuthDescription) - } - } - .frame(width: max(10, listSize.width - 50)) - } - } - - if signInPolicy == .requirePin { - Section { - TextField(L10n.hint, text: $pinHint) - } header: { - Text(L10n.hint) - } footer: { - Text(L10n.setPinHintDescription) - } - } - } - .animation(.linear, value: signInPolicy) - .navigationTitle(L10n.security) - .navigationBarTitleDisplayMode(.inline) - .onFirstAppear { - pinHint = viewModel.userSession.user.pinHint - signInPolicy = viewModel.userSession.user.accessPolicy - } - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case .promptForOldDeviceAuth: - Task { @MainActor in - try await performDeviceAuthentication( - reason: L10n.userRequiresDeviceAuthentication(viewModel.userSession.user.username) - ) - - checkNewPolicy() - } - case .promptForOldPin: - onPinCompletion = { - Task { - try viewModel.check(oldPin: pin) - - checkNewPolicy() - } - } - - pin = "" - isPresentingOldPinPrompt = true - case .promptForNewDeviceAuth: - Task { @MainActor in - try await performDeviceAuthentication( - reason: L10n.userRequiresDeviceAuthentication(viewModel.userSession.user.username) - ) - - viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: "") - router.popLast() - } - case .promptForNewPin: - onPinCompletion = { - viewModel.set(newPolicy: signInPolicy, newPin: pin, newPinHint: pinHint) - router.popLast() - } - - pin = "" - isPresentingNewPinPrompt = true - } - } - .topBarTrailing { - Button { - checkOldPolicy() - } label: { - Group { - if signInPolicy == .requirePin, signInPolicy == viewModel.userSession.user.accessPolicy { - Text(L10n.changePin) - } else { - Text(L10n.save) - } - } - .foregroundStyle(accentColor.overlayColor) - .font(.headline) - .padding(.vertical, 5) - .padding(.horizontal, 10) - .background { - accentColor - } - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - } - .trackingSize($listSize) - .alert( - L10n.enterPin, - isPresented: $isPresentingOldPinPrompt, - presenting: onPinCompletion - ) { completion in - - TextField(L10n.pin, text: $pin) - .keyboardType(.numberPad) - - // bug in SwiftUI: having .disabled will dismiss - // alert but not call the closure (for length) - Button(L10n.continue) { - completion() - } - - Button(L10n.cancel, role: .cancel) {} - } message: { _ in - Text(L10n.enterPinForUser(viewModel.userSession.user.username)) - } - .alert( - L10n.setPin, - isPresented: $isPresentingNewPinPrompt, - presenting: onPinCompletion - ) { completion in - - TextField(L10n.pin, text: $pin) - .keyboardType(.numberPad) - - // bug in SwiftUI: having .disabled will dismiss - // alert but not call the closure (for length) - Button(L10n.set) { - completion() - } - - Button(L10n.cancel, role: .cancel) {} - } message: { _ in - Text(L10n.createPinForUser(viewModel.userSession.user.username)) - } - .errorMessage($error) - } -} diff --git a/jellypig iOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift b/jellypig iOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift deleted file mode 100644 index 16f30ff1..00000000 --- a/jellypig iOS/Views/SettingsView/UserProfileSettingsView/UserProfileSettingsView.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import JellyfinAPI -import SwiftUI - -struct UserProfileSettingsView: View { - - @EnvironmentObject - private var router: SettingsCoordinator.Router - - @ObservedObject - private var viewModel: SettingsViewModel - @StateObject - private var profileImageViewModel: UserProfileImageViewModel - - @State - private var isPresentingConfirmReset: Bool = false - - init(viewModel: SettingsViewModel) { - self.viewModel = viewModel - self._profileImageViewModel = StateObject(wrappedValue: UserProfileImageViewModel(user: viewModel.userSession.user.data)) - } - - var body: some View { - List { - UserProfileHeroImage( - user: profileImageViewModel.user, - source: viewModel.userSession.user.profileImageSource( - client: viewModel.userSession.client, - maxWidth: 150 - ) - ) { - router.route(to: \.photoPicker, profileImageViewModel) - } onDelete: { - profileImageViewModel.send(.delete) - } - - Section { - ChevronButton(L10n.quickConnect) { - router.route(to: \.quickConnect, viewModel.userSession.user.data) - } - - ChevronButton(L10n.password) { - router.route(to: \.resetUserPassword, viewModel.userSession.user.id) - } - } - - Section { - ChevronButton(L10n.security) { - router.route(to: \.localSecurity) - } - } - - Section { - // TODO: move under future "Storage" tab - // when downloads implemented - Button(L10n.resetSettings) { - isPresentingConfirmReset = true - } - .foregroundStyle(.red) - } footer: { - Text(L10n.resetSettingsDescription) - } - } - .confirmationDialog( - L10n.resetSettings, - isPresented: $isPresentingConfirmReset, - titleVisibility: .visible - ) { - Button(L10n.reset, role: .destructive) { - do { - try viewModel.userSession.user.deleteSettings() - } catch { - viewModel.logger.error("Unable to reset user settings: \(error.localizedDescription)") - } - } - } message: { - Text(L10n.resetSettingsMessage) - } - } -} diff --git a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift b/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift deleted file mode 100644 index 27f84cda..00000000 --- a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/ActionButtonSelectorView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct ActionButtonSelectorView: View { - - @Binding - var selection: [VideoPlayerActionButton] - - var body: some View { - OrderedSectionSelectorView( - selection: $selection, - sources: VideoPlayerActionButton.allCases - ) - .label { button in - HStack { - Image(systemName: button.settingsSystemImage) - - Text(button.displayTitle) - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/ButtonSection.swift b/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/ButtonSection.swift deleted file mode 100644 index 20118b2a..00000000 --- a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/ButtonSection.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayerSettingsView { - struct ButtonSection: View { - - @Default(.VideoPlayer.Overlay.playbackButtonType) - private var playbackButtonType - - @Default(.VideoPlayer.showJumpButtons) - private var showJumpButtons - - @Default(.VideoPlayer.barActionButtons) - private var barActionButtons - - @Default(.VideoPlayer.menuActionButtons) - private var menuActionButtons - - @Default(.VideoPlayer.autoPlayEnabled) - private var autoPlayEnabled - - @EnvironmentObject - private var router: VideoPlayerSettingsCoordinator.Router - - var body: some View { - Section(L10n.buttons) { - - CaseIterablePicker(L10n.playbackButtons, selection: $playbackButtonType) - - Toggle(isOn: $showJumpButtons) { - HStack { - Image(systemName: "goforward") - Text(L10n.jump) - } - } - - ChevronButton(L10n.barButtons) { - router.route(to: \.actionButtonSelector, $barActionButtons) - } - - ChevronButton(L10n.menuButtons) { - router.route(to: \.actionButtonSelector, $menuActionButtons) - } - } - .onChange(of: barActionButtons) { newValue in - autoPlayEnabled = newValue.contains(.autoPlay) || menuActionButtons.contains(.autoPlay) - } - .onChange(of: menuActionButtons) { newValue in - autoPlayEnabled = newValue.contains(.autoPlay) || barActionButtons.contains(.autoPlay) - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SliderSection.swift b/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SliderSection.swift deleted file mode 100644 index cfe89cc4..00000000 --- a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SliderSection.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayerSettingsView { - struct SliderSection: View { - - @Default(.VideoPlayer.Overlay.chapterSlider) - private var chapterSlider - - @Default(.VideoPlayer.Overlay.sliderColor) - private var sliderColor - - @Default(.VideoPlayer.Overlay.sliderType) - private var sliderType - - var body: some View { - Section(L10n.slider) { - - Toggle(L10n.chapterSlider, isOn: $chapterSlider) - - ColorPicker(selection: $sliderColor, supportsOpacity: false) { - Text(L10n.sliderColor) - } - - CaseIterablePicker(L10n.sliderType, selection: $sliderType) - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SubtitleSection.swift b/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SubtitleSection.swift deleted file mode 100644 index ff585745..00000000 --- a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/SubtitleSection.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayerSettingsView { - struct SubtitleSection: View { - @Default(.VideoPlayer.Subtitle.subtitleFontName) - private var subtitleFontName - @Default(.VideoPlayer.Subtitle.subtitleSize) - private var subtitleSize - @Default(.VideoPlayer.Subtitle.subtitleColor) - private var subtitleColor - - @EnvironmentObject - private var router: VideoPlayerSettingsCoordinator.Router - - var body: some View { - Section { - ChevronButton(L10n.subtitleFont, subtitle: subtitleFontName) { - router.route(to: \.fontPicker, $subtitleFontName) - } - - BasicStepper( - title: L10n.subtitleSize, - value: $subtitleSize, - range: 1 ... 24, - step: 1 - ) - - ColorPicker(selection: $subtitleColor, supportsOpacity: false) { - Text(L10n.subtitleColor) - } - } header: { - Text(L10n.subtitle) - } footer: { - // TODO: better wording - Text(L10n.subtitlesDisclaimer) - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/TimestampSection.swift b/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/TimestampSection.swift deleted file mode 100644 index 4597464b..00000000 --- a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/TimestampSection.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayerSettingsView { - struct TimestampSection: View { - - @Default(.VideoPlayer.Overlay.trailingTimestampType) - private var trailingTimestampType - @Default(.VideoPlayer.Overlay.showCurrentTimeWhileScrubbing) - private var showCurrentTimeWhileScrubbing - @Default(.VideoPlayer.Overlay.timestampType) - private var timestampType - - var body: some View { - Section(L10n.timestamp) { - - Toggle(L10n.scrubCurrentTime, isOn: $showCurrentTimeWhileScrubbing) - - CaseIterablePicker(L10n.timestampType, selection: $timestampType) - - CaseIterablePicker(L10n.trailingValue, selection: $trailingTimestampType) - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/TransitionSection.swift b/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/TransitionSection.swift deleted file mode 100644 index e4ba36c7..00000000 --- a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/Components/Sections/TransitionSection.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayerSettingsView { - struct TransitionSection: View { - @Default(.VideoPlayer.Transition.pauseOnBackground) - private var pauseOnBackground - @Default(.VideoPlayer.Transition.playOnActive) - private var playOnActive - - var body: some View { - Section(L10n.transition) { - - Toggle(L10n.pauseOnBackground, isOn: $pauseOnBackground) - Toggle(L10n.playOnActive, isOn: $playOnActive) - } - } - } -} diff --git a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift b/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift deleted file mode 100644 index 62fa39d6..00000000 --- a/jellypig iOS/Views/SettingsView/VideoPlayerSettingsView/VideoPlayerSettingsView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct VideoPlayerSettingsView: View { - - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - @Default(.VideoPlayer.resumeOffset) - private var resumeOffset - - @EnvironmentObject - private var router: VideoPlayerSettingsCoordinator.Router - - var body: some View { - Form { - - ChevronButton(L10n.gestures) { - router.route(to: \.gestureSettings) - } - - CaseIterablePicker(L10n.jumpBackwardLength, selection: $jumpBackwardLength) - - CaseIterablePicker(L10n.jumpForwardLength, selection: $jumpForwardLength) - - Section { - - BasicStepper( - title: L10n.resumeOffset, - value: $resumeOffset, - range: 0 ... 30, - step: 1 - ) - .valueFormatter { - $0.secondLabel - } - } footer: { - Text(L10n.resumeOffsetDescription) - } - - ButtonSection() - - SliderSection() - - SubtitleSection() - - TimestampSection() - - TransitionSection() - } - .navigationTitle(L10n.videoPlayer) - } -} diff --git a/jellypig iOS/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift b/jellypig iOS/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift deleted file mode 100644 index 6deca814..00000000 --- a/jellypig iOS/Views/UserProfileImagePicker/Components/UserProfileImageCropView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import Mantis -import SwiftUI - -struct UserProfileImageCropView: View { - - // MARK: - State, Observed, & Environment Objects - - @EnvironmentObject - private var router: UserProfileImageCoordinator.Router - - @ObservedObject - var viewModel: UserProfileImageViewModel - - // MARK: - Image Variable - - let image: UIImage - - // MARK: - Error State - - @State - private var error: Error? - - // MARK: - Body - - var body: some View { - PhotoCropView( - isSaving: viewModel.state == .uploading, - image: image, - cropShape: .square, - presetRatio: .alwaysUsingOnePresetFixedRatio(ratio: 1) - ) { - viewModel.send(.upload($0)) - } onCancel: { - router.dismissCoordinator() - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .interactiveDismissDisabled(viewModel.state == .uploading) - .navigationBarBackButtonHidden(viewModel.state == .uploading) - .onReceive(viewModel.events) { event in - switch event { - case let .error(eventError): - error = eventError - case .deleted: - break - case .uploaded: - router.dismissCoordinator() - } - } - .errorMessage($error) - } -} diff --git a/jellypig iOS/Views/UserProfileImagePicker/UserProfileImagePickerView.swift b/jellypig iOS/Views/UserProfileImagePicker/UserProfileImagePickerView.swift deleted file mode 100644 index 4d486020..00000000 --- a/jellypig iOS/Views/UserProfileImagePicker/UserProfileImagePickerView.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct UserProfileImagePickerView: View { - - // MARK: - Observed, & Environment Objects - - @EnvironmentObject - private var router: UserProfileImageCoordinator.Router - - // MARK: - Body - - var body: some View { - PhotoPickerView { - router.route(to: \.cropImage, $0) - } onCancel: { - router.dismissCoordinator() - } - } -} diff --git a/jellypig iOS/Views/UserSignInView/Components/PublicUserRow.swift b/jellypig iOS/Views/UserSignInView/Components/PublicUserRow.swift deleted file mode 100644 index 9e41443a..00000000 --- a/jellypig iOS/Views/UserSignInView/Components/PublicUserRow.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension UserSignInView { - - struct PublicUserRow: View { - - @Environment(\.colorScheme) - private var colorScheme - - private let user: UserDto - private let client: JellyfinClient - private let action: () -> Void - - init( - user: UserDto, - client: JellyfinClient, - action: @escaping () -> Void - ) { - self.user = user - self.client = client - self.action = action - } - - var body: some View { - Button { - action() - } label: { - HStack { - ZStack { - Color.clear - - UserProfileImage( - userID: user.id, - source: user.profileImageSource( - client: client, - maxWidth: 120 - ) - ) - } - .frame(width: 50, height: 50) - - Text(user.name ?? .emptyDash) - .fontWeight(.semibold) - .foregroundStyle(.primary) - .lineLimit(1) - - Spacer() - - Image(systemName: "chevron.right") - .font(.body.weight(.regular)) - .foregroundColor(.secondary) - } - } - .foregroundStyle(.primary) - } - } -} diff --git a/jellypig iOS/Views/UserSignInView/Components/UserSignInSecurityView.swift b/jellypig iOS/Views/UserSignInView/Components/UserSignInSecurityView.swift deleted file mode 100644 index 8815f704..00000000 --- a/jellypig iOS/Views/UserSignInView/Components/UserSignInSecurityView.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// Note: this could be renamed from Security, but that's all it's used for atow - -extension UserSignInView { - - struct SecurityView: View { - - @EnvironmentObject - private var router: UserSignInCoordinator.Router - - @Binding - private var pinHint: String - @Binding - private var accessPolicy: UserAccessPolicy - - @State - private var listSize: CGSize = .zero - @State - private var updatePinHint: String - @State - private var updateSignInPolicy: UserAccessPolicy - - init( - pinHint: Binding, - accessPolicy: Binding - ) { - self._pinHint = pinHint - self._accessPolicy = accessPolicy - self._updatePinHint = State(initialValue: pinHint.wrappedValue) - self._updateSignInPolicy = State(initialValue: accessPolicy.wrappedValue) - } - - var body: some View { - List { - - Section { - CaseIterablePicker(L10n.security, selection: $updateSignInPolicy) - } footer: { - // TODO: descriptions of each section - - VStack(alignment: .leading, spacing: 10) { - Text( - L10n.additionalSecurityAccessDescription - ) - - // frame necessary with bug within BulletedList - BulletedList { - - VStack(alignment: .leading, spacing: 5) { - Text(UserAccessPolicy.requireDeviceAuthentication.displayTitle) - .fontWeight(.semibold) - - Text(L10n.requireDeviceAuthDescription) - } - .padding(.bottom, 15) - - VStack(alignment: .leading, spacing: 5) { - Text(UserAccessPolicy.requirePin.displayTitle) - .fontWeight(.semibold) - - Text(L10n.requirePinDescription) - } - .padding(.bottom, 15) - - VStack(alignment: .leading, spacing: 5) { - Text(UserAccessPolicy.none.displayTitle) - .fontWeight(.semibold) - - Text(L10n.saveUserWithoutAuthDescription) - } - } - .frame(width: max(10, listSize.width - 50)) - } - } - - if accessPolicy == .requirePin { - Section { - TextField(L10n.hint, text: $updatePinHint) - } header: { - Text(L10n.hint) - } footer: { - Text(L10n.setPinHintDescription) - } - } - } - .animation(.linear, value: accessPolicy) - .navigationTitle(L10n.security) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.popLast() - } - .onChange(of: updatePinHint) { newValue in - let truncated = String(newValue.prefix(120)) - updatePinHint = truncated - pinHint = truncated - } - .onChange(of: updatePinHint) { newValue in - pinHint = newValue - } - .onChange(of: updateSignInPolicy) { newValue in - accessPolicy = newValue - } - .trackingSize($listSize) - } - } -} diff --git a/jellypig iOS/Views/UserSignInView/UserSignInView.swift b/jellypig iOS/Views/UserSignInView/UserSignInView.swift deleted file mode 100644 index aa837237..00000000 --- a/jellypig iOS/Views/UserSignInView/UserSignInView.swift +++ /dev/null @@ -1,398 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Factory -import LocalAuthentication -import Stinsen -import SwiftUI - -// TODO: ignore device authentication `canceled by user` NSError -// TODO: fix duplicate user -// - could be good to replace access token -// - check against current user policy - -struct UserSignInView: View { - - // MARK: - Defaults - - @Default(.accentColor) - private var accentColor - - // MARK: - Focus Fields - - @FocusState - private var focusedTextField: Int? - - // MARK: - State & Environment Objects - - @EnvironmentObject - private var router: UserSignInCoordinator.Router - - @StateObject - private var viewModel: UserSignInViewModel - - // MARK: - User Signin Variables - - @State - private var duplicateUser: UserState? = nil - @State - private var onPinCompletion: (() -> Void)? = nil - @State - private var password: String = "" - @State - private var pin: String = "" - @State - private var pinHint: String = "" - @State - private var accessPolicy: UserAccessPolicy = .none - @State - private var username: String = "" - - // MARK: - Error State - - @State - private var isPresentingDuplicateUser: Bool = false - @State - private var isPresentingLocalPin: Bool = false - - // MARK: - Error State - - @State - private var error: Error? = nil - - // MARK: - Initializer - - init(server: ServerState) { - self._viewModel = StateObject(wrappedValue: UserSignInViewModel(server: server)) - } - - // MARK: - Handle Sign In - - private func handleSignIn(_ event: UserSignInViewModel.Event) { - switch event { - case let .duplicateUser(duplicateUser): - UIDevice.impact(.medium) - - self.duplicateUser = duplicateUser - isPresentingDuplicateUser = true - case let .error(eventError): - UIDevice.feedback(.error) - error = eventError - case let .signedIn(user): - UIDevice.feedback(.success) - - Defaults[.lastSignedInUserID] = .signedIn(userID: user.id) - Container.shared.currentUserSession.reset() - Notifications[.didSignIn].post() - } - } - - // MARK: - Open Quick Connect - - // TODO: don't have multiple ways to handle device authentication vs required pin - private func openQuickConnect(needsPin: Bool = true) { - Task { - switch accessPolicy { - case .none: () - case .requireDeviceAuthentication: - try await performDeviceAuthentication(reason: L10n.requireDeviceAuthForQuickConnectUser) - case .requirePin: - if needsPin { - onPinCompletion = { - router.route(to: \.quickConnect, viewModel.quickConnect) - } - isPresentingLocalPin = true - return - } - } - - router.route(to: \.quickConnect, viewModel.quickConnect) - } - } - - // MARK: - Sign In User Password - - private func signInUserPassword(needsPin: Bool = true) { - Task { - switch accessPolicy { - case .none: () - case .requireDeviceAuthentication: - try await performDeviceAuthentication(reason: L10n.requireDeviceAuthForUser(username)) - case .requirePin: - if needsPin { - onPinCompletion = { - viewModel.send(.signIn(username: username, password: password, policy: accessPolicy)) - } - isPresentingLocalPin = true - return - } - } - - viewModel.send(.signIn(username: username, password: password, policy: accessPolicy)) - } - } - - // MARK: - Sign In Duplicate User - - private func signInDuplicate(user: UserState, needsPin: Bool = true, replace: Bool) { - Task { - switch user.accessPolicy { - case .none: () - case .requireDeviceAuthentication: - try await performDeviceAuthentication(reason: L10n.userRequiresDeviceAuthentication(user.username)) - case .requirePin: - onPinCompletion = { - viewModel.send(.signInDuplicate(user, replace: replace)) - } - isPresentingLocalPin = true - return - } - - viewModel.send(.signInDuplicate(user, replace: replace)) - } - } - - // MARK: - Perform Pin Authentication - - private func performPinAuthentication() async throws { - isPresentingLocalPin = true - - guard pin.count > 4, pin.count < 30 else { - throw JellyfinAPIError(L10n.deviceAuthFailed) - } - } - - // MARK: - Perform Device Authentication - - // error logging/presentation is handled within here, just - // use try+thrown error in local Task for early return - private func performDeviceAuthentication(reason: String) async throws { - let context = LAContext() - var policyError: NSError? - - guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &policyError) else { - viewModel.logger.critical("\(policyError!.localizedDescription)") - - await MainActor.run { - self - .error = - JellyfinAPIError(L10n.unableToPerformDeviceAuthFaceID) - } - - throw JellyfinAPIError(L10n.deviceAuthFailed) - } - - do { - try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) - } catch { - viewModel.logger.critical("\(error.localizedDescription)") - - await MainActor.run { - self.error = JellyfinAPIError(L10n.unableToPerformDeviceAuth) - } - - throw JellyfinAPIError(L10n.deviceAuthFailed) - } - } - - // MARK: - Sign In Section - - @ViewBuilder - private var signInSection: some View { - Section { - TextField(L10n.username, text: $username) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .focused($focusedTextField, equals: 0) - .onSubmit { - focusedTextField = 1 - } - - UnmaskSecureField(L10n.password, text: $password) { - focusedTextField = nil - - signInUserPassword() - } - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .focused($focusedTextField, equals: 1) - } header: { - Text(L10n.signInToServer(viewModel.server.name)) - } footer: { - switch accessPolicy { - case .requireDeviceAuthentication: - Label(L10n.userDeviceAuthRequiredDescription, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - case .requirePin: - Label(L10n.userPinRequiredDescription, systemImage: "exclamationmark.circle.fill") - .labelStyle(.sectionFooterWithImage(imageStyle: .orange)) - case .none: - EmptyView() - } - } - - if case .signingIn = viewModel.state { - ListRowButton(L10n.cancel) { - viewModel.send(.cancel) - } - .foregroundStyle(.red, .red.opacity(0.2)) - } else { - ListRowButton(L10n.signIn) { - focusedTextField = nil - - signInUserPassword() - } - .disabled(username.isEmpty) - .foregroundStyle( - accentColor.overlayColor, - accentColor - ) - .opacity(username.isEmpty ? 0.5 : 1) - } - - if viewModel.isQuickConnectEnabled { - Section { - ListRowButton(L10n.quickConnect) { - openQuickConnect() - } - .disabled(viewModel.state == .signingIn) - .foregroundStyle( - accentColor.overlayColor, - accentColor - ) - } - } - - if let disclaimer = viewModel.serverDisclaimer { - Section(L10n.disclaimer) { - Text(disclaimer) - .font(.callout) - } - } - } - - // MARK: - Public Users Section - - @ViewBuilder - private var publicUsersSection: some View { - Section(L10n.publicUsers) { - if viewModel.publicUsers.isEmpty { - L10n.noPublicUsers.text - .font(.callout) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity) - } else { - ForEach(viewModel.publicUsers, id: \.id) { user in - PublicUserRow( - user: user, - client: viewModel.server.client - ) { - username = user.name ?? "" - password = "" - focusedTextField = 1 - } - } - } - } - } - - // MARK: - Body - - var body: some View { - List { - signInSection - - publicUsersSection - } - .animation(.linear, value: viewModel.isQuickConnectEnabled) - .interactiveDismissDisabled(viewModel.state == .signingIn) - .navigationTitle(L10n.signIn) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton(disabled: viewModel.state == .signingIn) { - router.dismissCoordinator() - } - .onChange(of: isPresentingLocalPin) { newValue in - if newValue { - pin = "" - } else { - onPinCompletion = nil - } - } - .onChange(of: pin) { newValue in - StoredValues[.Temp.userLocalPin] = newValue - } - .onChange(of: pinHint) { newValue in - StoredValues[.Temp.userLocalPinHint] = newValue - } - .onChange(of: accessPolicy) { newValue in - // necessary for Quick Connect sign in, but could - // just use for general sign in - StoredValues[.Temp.userAccessPolicy] = newValue - } - .onReceive(viewModel.events) { event in - handleSignIn(event) - } - .onFirstAppear { - focusedTextField = 0 - viewModel.send(.getPublicData) - } - .topBarTrailing { - if viewModel.state == .signingIn || viewModel.backgroundStates.contains(.gettingPublicData) { - ProgressView() - } - - Button(L10n.security, systemImage: "gearshape.fill") { - let parameters = UserSignInCoordinator.SecurityParameters( - pinHint: $pinHint, - accessPolicy: $accessPolicy - ) - router.route(to: \.security, parameters) - } - } - .alert( - Text(L10n.duplicateUser), - isPresented: $isPresentingDuplicateUser, - presenting: duplicateUser - ) { _ in - - // TODO: uncomment when duplicate user fixed -// Button(L10n.signIn) { -// signInDuplicate(user: user, replace: false) -// } - -// Button("Replace") { -// signInDuplicate(user: user, replace: true) -// } - - Button(L10n.dismiss, role: .cancel) - } message: { duplicateUser in - Text(L10n.duplicateUserSaved(duplicateUser.username)) - } - .alert( - L10n.setPin, - isPresented: $isPresentingLocalPin, - presenting: onPinCompletion - ) { completion in - - TextField(L10n.pin, text: $pin) - .keyboardType(.numberPad) - - // bug in SwiftUI: having .disabled will dismiss - // alert but not call the closure (for length) - Button(L10n.signIn) { - completion() - } - - Button(L10n.cancel, role: .cancel) {} - } message: { _ in - Text(L10n.setPinForNewUser) - } - .errorMessage($error) - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Components/LoadingView.swift b/jellypig iOS/Views/VideoPlayer/Components/LoadingView.swift deleted file mode 100644 index 74391c0a..00000000 --- a/jellypig iOS/Views/VideoPlayer/Components/LoadingView.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Stinsen -import SwiftUI - -extension VideoPlayer { - - struct LoadingView: View { - - @EnvironmentObject - private var router: VideoPlayerCoordinator.Router - - var body: some View { - ZStack { - Color.black - - VStack(spacing: 10) { - - Text(L10n.retrievingMediaInformation) - .foregroundColor(.white) - - ProgressView() - - Button { - router.dismissCoordinator() - } label: { - Text(L10n.cancel) - .foregroundColor(.red) - .padding() - .overlay { - Capsule() - .stroke(Color.red, lineWidth: 1) - } - } - } - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Components/PlaybackSettingsView.swift b/jellypig iOS/Views/VideoPlayer/Components/PlaybackSettingsView.swift deleted file mode 100644 index 7a5d073b..00000000 --- a/jellypig iOS/Views/VideoPlayer/Components/PlaybackSettingsView.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import Stinsen -import SwiftUI -import VLCUI - -// TODO: organize - -struct PlaybackSettingsView: View { - - @EnvironmentObject - private var router: PlaybackSettingsCoordinator.Router - @EnvironmentObject - private var splitContentViewProxy: SplitContentViewProxy - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - @Environment(\.audioOffset) - @Binding - private var audioOffset - @Environment(\.subtitleOffset) - @Binding - private var subtitleOffset - - var body: some View { - Form { - Section { - - ChevronButton(L10n.videoPlayer) { - router.route(to: \.videoPlayerSettings) - } - - // TODO: playback information - } header: { - EmptyView() - } - - BasicStepper( - title: L10n.audioOffset, - value: _audioOffset.wrappedValue, - range: -30000 ... 30000, - step: 100 - ) - .valueFormatter { - $0.millisecondLabel - } - - BasicStepper( - title: L10n.subtitleOffset, - value: _subtitleOffset.wrappedValue, - range: -30000 ... 30000, - step: 100 - ) - .valueFormatter { - $0.millisecondLabel - } - - if viewModel.videoStreams.isNotEmpty { - Section(L10n.video) { - ForEach(viewModel.videoStreams, id: \.displayTitle) { mediaStream in - ChevronButton(mediaStream.displayTitle ?? .emptyDash) { - router.route(to: \.mediaStreamInfo, mediaStream) - } - } - } - } - - if viewModel.audioStreams.isNotEmpty { - Section(L10n.audio) { - ForEach(viewModel.audioStreams, id: \.displayTitle) { mediaStream in - ChevronButton(mediaStream.displayTitle ?? .emptyDash) { - router.route(to: \.mediaStreamInfo, mediaStream) - } - } - } - } - - if viewModel.subtitleStreams.isNotEmpty { - Section(L10n.subtitle) { - ForEach(viewModel.subtitleStreams, id: \.displayTitle) { mediaStream in - ChevronButton(mediaStream.displayTitle ?? .emptyDash) { - router.route(to: \.mediaStreamInfo, mediaStream) - } - } - } - } - } - .navigationTitle(L10n.playback) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - splitContentViewProxy.hide() - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift b/jellypig iOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift deleted file mode 100644 index 4edd47a4..00000000 --- a/jellypig iOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import AVKit -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct LiveNativeVideoPlayer: View { - - @EnvironmentObject - private var router: LiveVideoPlayerCoordinator.Router - - @ObservedObject - private var videoPlayerManager: LiveVideoPlayerManager - - init(manager: LiveVideoPlayerManager) { - self.videoPlayerManager = manager - } - - @ViewBuilder - private var playerView: some View { - LiveNativeVideoPlayerView(videoPlayerManager: videoPlayerManager) - } - - var body: some View { - Group { - if let _ = videoPlayerManager.currentViewModel { - playerView - } else { - VideoPlayer.LoadingView() - } - } - .navigationBarHidden() - .statusBarHidden() - .ignoresSafeArea() - } -} - -struct LiveNativeVideoPlayerView: UIViewControllerRepresentable { - - let videoPlayerManager: LiveVideoPlayerManager - - func makeUIViewController(context: Context) -> UILiveNativeVideoPlayerViewController { - UILiveNativeVideoPlayerViewController(manager: videoPlayerManager) - } - - func updateUIViewController(_ uiViewController: UILiveNativeVideoPlayerViewController, context: Context) {} -} - -class UILiveNativeVideoPlayerViewController: AVPlayerViewController { - - let videoPlayerManager: LiveVideoPlayerManager - - private var rateObserver: NSKeyValueObservation! - private var timeObserverToken: Any! - - init(manager: LiveVideoPlayerManager) { - - self.videoPlayerManager = manager - - super.init(nibName: nil, bundle: nil) - - let newPlayer: AVPlayer = .init(url: manager.currentViewModel.hlsPlaybackURL) - - newPlayer.allowsExternalPlayback = true - newPlayer.appliesMediaSelectionCriteriaAutomatically = false - newPlayer.currentItem?.externalMetadata = createMetadata() - - // enable pip - allowsPictureInPicturePlayback = true - - rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in - guard let newValue = change.newValue else { return } - - if newValue == 0 { - self.videoPlayerManager.onStateUpdated(newState: .paused) - } else { - self.videoPlayerManager.onStateUpdated(newState: .playing) - } - } - - let time = CMTime(seconds: 0.1, preferredTimescale: 1000) - - timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in - - guard let self else { return } - - if time.seconds >= 0 { - let newSeconds = Int(time.seconds) - let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds) - self.videoPlayerManager.currentProgressHandler.progress = progress - self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress - self.videoPlayerManager.currentProgressHandler.seconds = newSeconds - self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds - } - } - - player = newPlayer - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - stop() - guard let timeObserverToken else { return } - player?.removeTimeObserver(timeObserverToken) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - player?.seek( - to: CMTimeMake( - value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]), - timescale: 1 - ), - toleranceBefore: .zero, - toleranceAfter: .zero, - completionHandler: { _ in - self.play() - } - ) - } - - private func createMetadata() -> [AVMetadataItem] { - [] - -// let allMetadata: [AVMetadataIdentifier: Any?] = [ -// .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, -// .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, -// ] -// -// return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } - } - - private func createMetadataItem( - for identifier: AVMetadataIdentifier, - value: Any? - ) -> AVMetadataItem? { - guard let value else { return nil } - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as? AVMetadataItem - } - - private func play() { - player?.play() - - videoPlayerManager.sendStartReport() - } - - private func stop() { - player?.pause() - - videoPlayerManager.sendStopReport() - } -} diff --git a/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift b/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift deleted file mode 100644 index d7c572d8..00000000 --- a/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI -import VLCUI - -extension LiveVideoPlayer.Overlay { - - struct LiveBottomBarView: View { - - @Default(.VideoPlayer.Overlay.chapterSlider) - private var chapterSlider - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - @Default(.VideoPlayer.Overlay.playbackButtonType) - private var playbackButtonType - @Default(.VideoPlayer.Overlay.sliderType) - private var sliderType - @Default(.VideoPlayer.Overlay.timestampType) - private var timestampType - - @Environment(\.currentOverlayType) - @Binding - private var currentOverlayType - - @Environment(\.isPresentingOverlay) - @Binding - private var isPresentingOverlay - @Environment(\.isScrubbing) - @Binding - private var isScrubbing: Bool - - @EnvironmentObject - private var currentProgressHandler: LiveVideoPlayerManager.CurrentProgressHandler - @EnvironmentObject - private var overlayTimer: TimerProxy - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - @EnvironmentObject - private var videoPlayerManager: LiveVideoPlayerManager - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - @State - private var currentChapter: ChapterInfo.FullInfo? - - @ViewBuilder - private var capsuleSlider: some View { - CapsuleSlider(progress: $currentProgressHandler.scrubbedProgress) - .isEditing(_isScrubbing.wrappedValue) - .trackMask { - if chapterSlider && !viewModel.chapters.isEmpty { - VideoPlayer.Overlay.ChapterTrack() - .clipShape(Capsule()) - } else { - Color.white - } - } - .bottomContent { - Group { - switch timestampType { - case .split: - VideoPlayer.Overlay.SplitTimeStamp() - case .compact: - VideoPlayer.Overlay.CompactTimeStamp() - } - } - .padding(5) - } - .leadingContent { - if playbackButtonType == .compact { - VideoPlayer.Overlay.SmallPlaybackButtons() - .padding(.trailing) - .disabled(isScrubbing) - } - } - .frame(height: 50) - } - - @ViewBuilder - private var thumbSlider: some View { - ThumbSlider(progress: $currentProgressHandler.scrubbedProgress) - .isEditing(_isScrubbing.wrappedValue) - .trackMask { - if chapterSlider && !viewModel.chapters.isEmpty { - VideoPlayer.Overlay.ChapterTrack() - .clipShape(Capsule()) - } else { - Color.white - } - } - .bottomContent { - Group { - switch timestampType { - case .split: - VideoPlayer.Overlay.SplitTimeStamp() - case .compact: - VideoPlayer.Overlay.CompactTimeStamp() - } - } - .padding(5) - } - .leadingContent { - if playbackButtonType == .compact { - VideoPlayer.Overlay.SmallPlaybackButtons() - .padding(.trailing) - .disabled(isScrubbing) - } - } - } - - var body: some View { - VStack(spacing: 0) { - HStack { - if chapterSlider, let currentChapter { - Button { - currentOverlayType = .chapters - overlayTimer.stop() - } label: { - HStack { - Text(currentChapter.displayTitle) - .monospacedDigit() - - Image(systemName: "chevron.right") - } - .foregroundColor(.white) - .font(.subheadline.weight(.medium)) - } - .disabled(isScrubbing) - } - - Spacer() - } - .padding(.leading, 5) - .padding(.bottom, 15) - - Group { - switch sliderType { - case .capsule: capsuleSlider - case .thumb: thumbSlider - } - } - } - .onChange(of: currentProgressHandler.scrubbedSeconds) { newValue in - guard chapterSlider else { return } - let newChapter = viewModel.chapter(from: newValue) - if newChapter != currentChapter { - if isScrubbing { - UIDevice.impact(.light) - } - - self.currentChapter = newChapter - } - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift b/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift deleted file mode 100644 index 8659991e..00000000 --- a/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Stinsen -import SwiftUI -import VLCUI - -extension LiveVideoPlayer.Overlay { - - struct LiveTopBarView: View { - - @EnvironmentObject - private var router: LiveVideoPlayerCoordinator.Router - @EnvironmentObject - private var splitContentViewProxy: SplitContentViewProxy - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - var body: some View { - VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 0) { - HStack(alignment: .center) { - Button { - videoPlayerProxy.stop() - router.dismissCoordinator {} - } label: { - Image(systemName: "xmark") - .padding() - } - .buttonStyle(ScalingButtonStyle(scale: 0.8)) - - Text(viewModel.item.displayTitle) - .font(.title3) - .fontWeight(.bold) - .lineLimit(1) - .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in - dimensions[.leading] - } - - Spacer() - - VideoPlayer.Overlay.BarActionButtons() - .buttonStyle(ScalingButtonStyle(scale: 0.8)) - } - .font(.system(size: 24)) - .tint(Color.white) - .foregroundColor(Color.white) - -// if let subtitle = viewModel.item.subtitle { -// Text(subtitle) -// .font(.subheadline) -// .foregroundColor(.white) -// .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in -// dimensions[.leading] -// } -// .offset(y: -10) -// } - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveLargePlaybackButtons.swift b/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveLargePlaybackButtons.swift deleted file mode 100644 index 0c2b5aaa..00000000 --- a/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveLargePlaybackButtons.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import VLCUI - -extension LiveVideoPlayer.Overlay { - - struct LiveLargePlaybackButtons: View { - - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - @Default(.VideoPlayer.showJumpButtons) - private var showJumpButtons - - @EnvironmentObject - private var timerProxy: TimerProxy - @EnvironmentObject - private var videoPlayerManager: LiveVideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - - @ViewBuilder - private var jumpBackwardButton: some View { - Button { - videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) - timerProxy.start(5) - } label: { - Image(systemName: jumpBackwardLength.backwardImageLabel) - .font(.system(size: 36, weight: .regular, design: .default)) - .padding() - .contentShape(Rectangle()) - } - .contentShape(Rectangle()) - .buttonStyle(ScalingButtonStyle(scale: 0.9)) - } - - @ViewBuilder - private var playButton: some View { - Button { - switch videoPlayerManager.state { - case .playing: - videoPlayerProxy.pause() - default: - videoPlayerProxy.play() - } - timerProxy.start(5) - } label: { - Group { - switch videoPlayerManager.state { - case .stopped, .paused: - Image(systemName: "play.fill") - case .playing: - Image(systemName: "pause.fill") - default: - ProgressView() - .scaleEffect(2) - } - } - .font(.system(size: 56, weight: .bold, design: .default)) - .padding() - .transition(.opacity) - .contentShape(Rectangle()) - } - .contentShape(Rectangle()) - .buttonStyle(ScalingButtonStyle(scale: 0.9)) - } - - @ViewBuilder - private var jumpForwardButton: some View { - Button { - videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) - timerProxy.start(5) - } label: { - Image(systemName: jumpForwardLength.forwardImageLabel) - .font(.system(size: 36, weight: .regular, design: .default)) - .padding() - .contentShape(Rectangle()) - } - .contentShape(Rectangle()) - .buttonStyle(ScalingButtonStyle(scale: 0.9)) - } - - var body: some View { - HStack(spacing: 0) { - - Spacer(minLength: 100) - - if showJumpButtons { - jumpBackwardButton - } - - playButton - .frame(minWidth: 100, maxWidth: 300) - - if showJumpButtons { - jumpForwardButton - } - - Spacer(minLength: 100) - } - .tint(Color.white) - .foregroundColor(Color.white) - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveSmallPlaybackButton.swift b/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveSmallPlaybackButton.swift deleted file mode 100644 index 3c619c6b..00000000 --- a/jellypig iOS/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveSmallPlaybackButton.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import VLCUI - -extension LiveVideoPlayer.Overlay { - - struct LiveSmallPlaybackButtons: View { - - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - @Default(.VideoPlayer.showJumpButtons) - private var showJumpButtons - - @EnvironmentObject - private var timerProxy: TimerProxy - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - - @ViewBuilder - private var jumpBackwardButton: some View { - Button { - videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) - timerProxy.start(5) - } label: { - Image(systemName: jumpBackwardLength.backwardImageLabel) - .font(.system(size: 24, weight: .bold, design: .default)) - } - .contentShape(Rectangle()) - } - - @ViewBuilder - private var playButton: some View { - Button { - switch videoPlayerManager.state { - case .playing: - videoPlayerProxy.pause() - default: - videoPlayerProxy.play() - } - timerProxy.start(5) - } label: { - Group { - switch videoPlayerManager.state { - case .stopped, .paused: - Image(systemName: "play.fill") - case .playing: - Image(systemName: "pause.fill") - default: - ProgressView() - } - } - .font(.system(size: 28, weight: .bold, design: .default)) - .frame(width: 50, height: 50) - } - .contentShape(Rectangle()) - } - - @ViewBuilder - private var jumpForwardButton: some View { - Button { - videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) - timerProxy.start(5) - } label: { - Image(systemName: jumpForwardLength.forwardImageLabel) - .font(.system(size: 24, weight: .bold, design: .default)) - } - .contentShape(Rectangle()) - } - - var body: some View { - HStack(spacing: 15) { - - if showJumpButtons { - jumpBackwardButton - } - - playButton - - if showJumpButtons { - jumpForwardButton - } - } - .tint(Color.white) - .foregroundColor(Color.white) - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift b/jellypig iOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift deleted file mode 100644 index a6b42503..00000000 --- a/jellypig iOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension LiveVideoPlayer { - - struct LiveMainOverlay: View { - - @Default(.VideoPlayer.Overlay.playbackButtonType) - private var playbackButtonType - - @Environment(\.currentOverlayType) - @Binding - private var currentOverlayType - @Environment(\.isPresentingOverlay) - @Binding - private var isPresentingOverlay - @Environment(\.isScrubbing) - @Binding - private var isScrubbing: Bool - @Environment(\.safeAreaInsets) - private var safeAreaInsets - - @EnvironmentObject - private var splitContentViewProxy: SplitContentViewProxy - - @StateObject - private var overlayTimer: TimerProxy = .init() - - var body: some View { - ZStack { - VStack { - Overlay.LiveTopBarView() - .if(UIDevice.hasNotch) { view in - view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) - .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) - } - .if(UIDevice.isPad) { view in - view.padding() - } - .background { - LinearGradient( - stops: [ - .init(color: .black.opacity(0.9), location: 0), - .init(color: .clear, location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .visible(playbackButtonType == .compact) - } - .visible(!isScrubbing && isPresentingOverlay) - - Spacer() - .allowsHitTesting(false) - - Overlay.LiveBottomBarView() - .if(UIDevice.hasNotch) { view in - view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) - .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) - } - .if(UIDevice.isPad) { view in - view.padding() - } - .background { - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black.opacity(0.5), location: 0.5), - .init(color: .black.opacity(0.5), location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .visible(isScrubbing || playbackButtonType == .compact) - } - .background { - Color.clear - .allowsHitTesting(true) - .contentShape(Rectangle()) - .allowsHitTesting(true) - } - .visible(isScrubbing || isPresentingOverlay) - } - - if playbackButtonType == .large { - LiveVideoPlayer.Overlay.LiveLargePlaybackButtons() - .visible(!isScrubbing && isPresentingOverlay) - } - } - .environmentObject(overlayTimer) - .background { - Color.black - .opacity(!isScrubbing && playbackButtonType == .large && isPresentingOverlay ? 0.5 : 0) - .allowsHitTesting(false) - } - .animation(.linear(duration: 0.1), value: isScrubbing) - .onChange(of: isPresentingOverlay) { newValue in - guard newValue, !isScrubbing else { return } - overlayTimer.start(5) - } - .onChange(of: isScrubbing) { newValue in - if newValue { - overlayTimer.stop() - } else { - overlayTimer.start(5) - } - } - .onChange(of: overlayTimer.isActive) { newValue in - guard !newValue, !isScrubbing else { return } - - withAnimation(.linear(duration: 0.3)) { - isPresentingOverlay = false - } - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift b/jellypig iOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift deleted file mode 100644 index 2cd46930..00000000 --- a/jellypig iOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension LiveVideoPlayer { - - struct Overlay: View { - - @Environment(\.isPresentingOverlay) - @Binding - private var isPresentingOverlay - - @State - private var currentOverlayType: VideoPlayer.OverlayType = .main - - var body: some View { - ZStack { - - LiveMainOverlay() - .visible(currentOverlayType == .main) - } - .animation(.linear(duration: 0.1), value: currentOverlayType) - .environment(\.currentOverlayType, $currentOverlayType) - .onChange(of: isPresentingOverlay) { newValue in - guard newValue else { return } - currentOverlayType = .main - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/LiveVideoPlayer.swift b/jellypig iOS/Views/VideoPlayer/LiveVideoPlayer.swift deleted file mode 100644 index 4030ef7e..00000000 --- a/jellypig iOS/Views/VideoPlayer/LiveVideoPlayer.swift +++ /dev/null @@ -1,540 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import MediaPlayer -import Stinsen -import SwiftUI -import VLCUI - -// TODO: organize -// TODO: localization necessary for toast text? -// TODO: entire gesture layer should be separate - -struct LiveVideoPlayer: View { - - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - - @Default(.VideoPlayer.Gesture.horizontalPanGesture) - private var horizontalPanGesture - @Default(.VideoPlayer.Gesture.horizontalSwipeGesture) - private var horizontalSwipeGesture - @Default(.VideoPlayer.Gesture.longPressGesture) - private var longPressGesture - @Default(.VideoPlayer.Gesture.multiTapGesture) - private var multiTapGesture - @Default(.VideoPlayer.Gesture.doubleTouchGesture) - private var doubleTouchGesture - @Default(.VideoPlayer.Gesture.pinchGesture) - private var pinchGesture - @Default(.VideoPlayer.Gesture.verticalPanGestureLeft) - private var verticalGestureLeft - @Default(.VideoPlayer.Gesture.verticalPanGestureRight) - private var verticalGestureRight - - @Default(.VideoPlayer.Subtitle.subtitleColor) - private var subtitleColor - @Default(.VideoPlayer.Subtitle.subtitleFontName) - private var subtitleFontName - @Default(.VideoPlayer.Subtitle.subtitleSize) - private var subtitleSize - - @EnvironmentObject - private var router: LiveVideoPlayerCoordinator.Router - - @ObservedObject - private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler - @StateObject - private var splitContentViewProxy: SplitContentViewProxy = .init() - @ObservedObject - private var videoPlayerManager: LiveVideoPlayerManager - - @State - private var audioOffset: Int = 0 - @State - private var isAspectFilled: Bool = false - @State - private var isGestureLocked: Bool = false - @State - private var isPresentingOverlay: Bool = false - @State - private var isScrubbing: Bool = false - @State - private var playbackSpeed: Double = 1 - @State - private var subtitleOffset: Int = 0 - - private let gestureStateHandler: VideoPlayer.GestureStateHandler = .init() - private let updateViewProxy: UpdateViewProxy = .init() - - @ViewBuilder - private var playerView: some View { - SplitContentView(splitContentWidth: 400) - .proxy(splitContentViewProxy) - .content { - ZStack { - VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration) - .proxy(videoPlayerManager.proxy) - .onTicksUpdated { ticks, _ in - - let newSeconds = ticks / 1000 - var newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) - if newProgress.isInfinite || newProgress.isNaN { - newProgress = 0 - } - currentProgressHandler.progress = newProgress - currentProgressHandler.seconds = newSeconds - - guard !isScrubbing else { return } - currentProgressHandler.scrubbedProgress = newProgress - } - .onStateUpdated { state, _ in - - videoPlayerManager.onStateUpdated(newState: state) - - if state == .ended { - if let _ = videoPlayerManager.nextViewModel, - Defaults[.VideoPlayer.autoPlayEnabled] - { - videoPlayerManager.selectNextViewModel() - } else { - router.dismissCoordinator {} - } - } - } - - GestureView() - .onHorizontalPan { - handlePan(action: horizontalPanGesture, state: $0, point: $1.x, velocity: $2, translation: $3) - } - .onHorizontalSwipe(translation: 100, velocity: 1500, sameSwipeDirectionTimeout: 1, handleHorizontalSwipe) - .onLongPress(minimumDuration: 2, handleLongPress) - .onPinch(handlePinchGesture) - .onTap(samePointPadding: 10, samePointTimeout: 0.7, handleTapGesture) - .onDoubleTouch(handleDoubleTouchGesture) - .onVerticalPan { - if $1.x <= 0.5 { - handlePan(action: verticalGestureLeft, state: $0, point: -$1.y, velocity: $2, translation: $3) - } else { - handlePan(action: verticalGestureRight, state: $0, point: -$1.y, velocity: $2, translation: $3) - } - } - - LiveVideoPlayer.Overlay() - .environmentObject(splitContentViewProxy) - .environmentObject(videoPlayerManager) - .environmentObject(videoPlayerManager.currentProgressHandler) - .environmentObject(videoPlayerManager.currentViewModel!) - .environmentObject(videoPlayerManager.proxy) - .environment(\.aspectFilled, $isAspectFilled) - .environment(\.isPresentingOverlay, $isPresentingOverlay) - .environment(\.isScrubbing, $isScrubbing) - .environment(\.playbackSpeed, $playbackSpeed) - } - } - .splitContent { - // Wrapped due to navigation controller popping due to published changes - WrappedView { - NavigationViewCoordinator(PlaybackSettingsCoordinator()).view() - } - .cornerRadius(20, corners: [.topLeft, .bottomLeft]) - .environmentObject(splitContentViewProxy) - .environmentObject(videoPlayerManager) - .environmentObject(videoPlayerManager.currentViewModel) - .environment(\.audioOffset, $audioOffset) - .environment(\.subtitleOffset, $subtitleOffset) - } - .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in - guard !newValue.isNaN && !newValue.isInfinite else { - return - } - DispatchQueue.main.async { - videoPlayerManager.currentProgressHandler - .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) - } - } - .overlay(alignment: .top) { - UpdateView(proxy: updateViewProxy) - .padding(.top) - } - .videoPlayerKeyCommands( - gestureStateHandler: gestureStateHandler, - updateViewProxy: updateViewProxy - ) - } - - var body: some View { - Group { - if let _ = videoPlayerManager.currentViewModel { - playerView - } else { - VideoPlayer.LoadingView() - } - } - .navigationBarHidden(true) - .statusBar(hidden: true) - .ignoresSafeArea() - .onChange(of: audioOffset) { newValue in - videoPlayerManager.proxy.setAudioDelay(.ticks(newValue)) - } - .onChange(of: isGestureLocked) { newValue in - if newValue { - updateViewProxy.present(systemName: "lock.fill", title: L10n.gesturesLocked) - } else { - updateViewProxy.present(systemName: "lock.open.fill", title: L10n.gesturesUnlocked) - } - } - .onChange(of: isScrubbing) { newValue in - guard !newValue else { return } - videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds)) - } - .onChange(of: subtitleColor) { newValue in - videoPlayerManager.proxy.setSubtitleColor(.absolute(newValue.uiColor)) - } - .onChange(of: subtitleFontName) { newValue in - videoPlayerManager.proxy.setSubtitleFont(newValue) - } - .onChange(of: subtitleOffset) { newValue in - videoPlayerManager.proxy.setSubtitleDelay(.ticks(newValue)) - } - .onChange(of: subtitleSize) { newValue in - videoPlayerManager.proxy.setSubtitleSize(.absolute(24 - newValue)) - } - .onChange(of: videoPlayerManager.currentViewModel) { newViewModel in - guard let newViewModel else { return } - - videoPlayerManager.proxy.playNewMedia(newViewModel.vlcVideoPlayerConfiguration) - - isAspectFilled = false - audioOffset = 0 - subtitleOffset = 0 - } - } -} - -extension LiveVideoPlayer { - - init(manager: LiveVideoPlayerManager) { - self.init( - currentProgressHandler: manager.currentProgressHandler, - videoPlayerManager: manager - ) - } -} - -// MARK: Gestures - -// TODO: refactor to be split into other files -// TODO: refactor so that actions are separate from the gesture calculations, so that actions are more general - -extension LiveVideoPlayer { - - private func handlePan( - action: PanAction, - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - guard !isGestureLocked else { return } - - switch action { - case .none: - return - case .audioffset: - audioOffsetAction(state: state, point: point, velocity: velocity, translation: translation) - case .brightness: - brightnessAction(state: state, point: point, velocity: velocity, translation: translation) - case .playbackSpeed: - playbackSpeedAction(state: state, point: point, velocity: velocity, translation: translation) - case .scrub: - scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 1) - case .slowScrub: - scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 0.1) - case .subtitleOffset: - subtitleOffsetAction(state: state, point: point, velocity: velocity, translation: translation) - case .volume: - volumeAction(state: state, point: point, velocity: velocity, translation: translation) - } - } - - private func handleHorizontalSwipe( - unitPoint: UnitPoint, - direction: Bool, - amount: Int - ) { - guard !isGestureLocked else { return } - - switch horizontalSwipeGesture { - case .none: - return - case .jump: - jumpAction(unitPoint: .init(x: direction ? 1 : 0, y: 0), amount: amount) - } - } - - private func handleLongPress(point: UnitPoint) { - switch longPressGesture { - case .none: - return - case .gestureLock: - guard !isPresentingOverlay else { return } - isGestureLocked.toggle() - } - } - - private func handlePinchGesture(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) { - guard !isGestureLocked else { return } - - switch pinchGesture { - case .none: - return - case .aspectFill: - aspectFillAction(state: state, unitPoint: unitPoint, scale: scale) - } - } - - private func handleTapGesture(unitPoint: UnitPoint, taps: Int) { - guard !isGestureLocked else { - updateViewProxy.present(systemName: "lock.fill", title: L10n.gesturesLocked) - return - } - - if taps > 1 && multiTapGesture != .none { - - withAnimation(.linear(duration: 0.1)) { - isPresentingOverlay = false - } - - switch multiTapGesture { - case .none: - return - case .jump: - jumpAction(unitPoint: unitPoint, amount: taps - 1) - } - } else { - withAnimation(.linear(duration: 0.1)) { - isPresentingOverlay = !isPresentingOverlay - } - } - } - - private func handleDoubleTouchGesture(unitPoint: UnitPoint, taps: Int) { - guard !isGestureLocked else { - updateViewProxy.present(systemName: "lock.fill", title: L10n.gesturesLocked) - return - } - - switch doubleTouchGesture { - case .none: - return - case .aspectFill: () -// aspectFillAction(state: state, unitPoint: unitPoint, scale: <#T##CGFloat#>) - case .gestureLock: - guard !isPresentingOverlay else { return } - isGestureLocked.toggle() - case .pausePlay: () - } - } -} - -// MARK: Actions - -extension LiveVideoPlayer { - - private func aspectFillAction(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) { - guard state == .began || state == .changed else { return } - if scale > 1, !isAspectFilled { - isAspectFilled = true - UIView.animate(withDuration: 0.2) { - videoPlayerManager.proxy.aspectFill(1) - } - } else if scale < 1, isAspectFilled { - isAspectFilled = false - UIView.animate(withDuration: 0.2) { - videoPlayerManager.proxy.aspectFill(0) - } - } - } - - private func audioOffsetAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningAudioOffset = audioOffset - } else if state == .ended { - return - } - - let newOffset = gestureStateHandler.beginningAudioOffset - round( - Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000), - toNearest: 100 - ) - - updateViewProxy.present(systemName: "speaker.wave.2.fill", title: newOffset.millisecondLabel) - audioOffset = clamp(newOffset, min: -30000, max: 30000) - } - - private func brightnessAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningBrightnessValue = UIScreen.main.brightness - } else if state == .ended { - return - } - - let newBrightness = gestureStateHandler.beginningBrightnessValue - (gestureStateHandler.beginningHorizontalPanUnit - point) - let clampedBrightness = clamp(newBrightness, min: 0, max: 1.0) - let flashPercentage = Int(clampedBrightness * 100) - - if flashPercentage >= 67 { - updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%", iconSize: .init(width: 30, height: 30)) - } else if flashPercentage >= 33 { - updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%") - } else { - updateViewProxy.present(systemName: "sun.min.fill", title: "\(flashPercentage)%", iconSize: .init(width: 20, height: 20)) - } - - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) { - UIScreen.main.brightness = clampedBrightness - } - } - - // TODO: decide on overlay behavior? - private func jumpAction( - unitPoint: UnitPoint, - amount: Int - ) { - if unitPoint.x <= 0.5 { - videoPlayerManager.proxy.jumpBackward(Int(jumpBackwardLength.rawValue)) - - updateViewProxy.present(systemName: "gobackward", title: "\(amount * Int(jumpBackwardLength.rawValue))s") - } else { - videoPlayerManager.proxy.jumpForward(Int(jumpForwardLength.rawValue)) - - updateViewProxy.present(systemName: "goforward", title: "\(amount * Int(jumpForwardLength.rawValue))s") - } - } - - private func playbackSpeedAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningPlaybackSpeed = playbackSpeed - } else if state == .ended { - return - } - - let newPlaybackSpeed = round( - gestureStateHandler.beginningPlaybackSpeed - Double(gestureStateHandler.beginningHorizontalPanUnit - point) * 2, - toNearest: 0.25 - ) - let clampedPlaybackSpeed = clamp(newPlaybackSpeed, min: 0.25, max: 5.0) - - updateViewProxy.present(systemName: "speedometer", title: clampedPlaybackSpeed.rateLabel) - - playbackSpeed = clampedPlaybackSpeed - videoPlayerManager.proxy.setRate(.absolute(Float(clampedPlaybackSpeed))) - } - - private func scrubAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat, - rate: CGFloat - ) { - if state == .began { - isScrubbing = true - - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beganPanWithOverlay = isPresentingOverlay - } else if state == .ended { - if !gestureStateHandler.beganPanWithOverlay { - isPresentingOverlay = false - } - - isScrubbing = false - - return - } - - let newProgress = gestureStateHandler.beginningPanProgress - (gestureStateHandler.beginningHorizontalPanUnit - point) * rate - currentProgressHandler.scrubbedProgress = clamp(newProgress, min: 0, max: 1) - } - - private func subtitleOffsetAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningSubtitleOffset = subtitleOffset - } else if state == .ended { - return - } - - let newOffset = gestureStateHandler.beginningSubtitleOffset - round( - Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000), - toNearest: 100 - ) - let clampedOffset = clamp(newOffset, min: -30000, max: 30000) - - updateViewProxy.present(systemName: "captions.bubble.fill", title: clampedOffset.millisecondLabel) - - subtitleOffset = clampedOffset - } - - private func volumeAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - let volumeView = MPVolumeView() - guard let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider else { return } - - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningVolumeValue = AVAudioSession.sharedInstance().outputVolume - } else if state == .ended { - return - } - - let newVolume = gestureStateHandler.beginningVolumeValue - Float(gestureStateHandler.beginningHorizontalPanUnit - point) - - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) { - slider.value = newVolume - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/NativeVideoPlayer.swift b/jellypig iOS/Views/VideoPlayer/NativeVideoPlayer.swift deleted file mode 100644 index d8f8f101..00000000 --- a/jellypig iOS/Views/VideoPlayer/NativeVideoPlayer.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import AVKit -import Combine -import Defaults -import JellyfinAPI -import SwiftUI - -struct NativeVideoPlayer: View { - - @Environment(\.scenePhase) - var scenePhase - - @EnvironmentObject - private var router: VideoPlayerCoordinator.Router - - @ObservedObject - private var videoPlayerManager: VideoPlayerManager - - init(manager: VideoPlayerManager) { - self.videoPlayerManager = manager - } - - @ViewBuilder - private var playerView: some View { - NativeVideoPlayerView(videoPlayerManager: videoPlayerManager) - } - - var body: some View { - Group { - if let _ = videoPlayerManager.currentViewModel { - playerView - } else { - VideoPlayer.LoadingView() - } - } - .navigationBarHidden() - .statusBarHidden() - .ignoresSafeArea() - } -} - -struct NativeVideoPlayerView: UIViewControllerRepresentable { - - let videoPlayerManager: VideoPlayerManager - - func makeUIViewController(context: Context) -> UINativeVideoPlayerViewController { - UINativeVideoPlayerViewController(manager: videoPlayerManager) - } - - func updateUIViewController(_ uiViewController: UINativeVideoPlayerViewController, context: Context) {} -} - -class UINativeVideoPlayerViewController: AVPlayerViewController { - - let videoPlayerManager: VideoPlayerManager - - private var rateObserver: NSKeyValueObservation! - private var timeObserverToken: Any! - - init(manager: VideoPlayerManager) { - - self.videoPlayerManager = manager - - super.init(nibName: nil, bundle: nil) - - let newPlayer: AVPlayer = .init(url: manager.currentViewModel.playbackURL) - - newPlayer.allowsExternalPlayback = true - newPlayer.appliesMediaSelectionCriteriaAutomatically = false - newPlayer.currentItem?.externalMetadata = createMetadata() - - // enable pip - allowsPictureInPicturePlayback = true - - rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in - guard let newValue = change.newValue else { return } - - if newValue == 0 { - self.videoPlayerManager.onStateUpdated(newState: .paused) - } else { - self.videoPlayerManager.onStateUpdated(newState: .playing) - } - } - - let time = CMTime(seconds: 0.1, preferredTimescale: 1000) - - timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in - - guard let self else { return } - - if time.seconds >= 0 { - let newSeconds = Int(time.seconds) - let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds) - - self.videoPlayerManager.currentProgressHandler.progress = progress - self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress - self.videoPlayerManager.currentProgressHandler.seconds = newSeconds - self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds - } - } - - player = newPlayer - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - // Add external screen support configuration - player?.usesExternalPlaybackWhileExternalScreenIsActive = true - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - stop() - guard let timeObserverToken else { return } - player?.removeTimeObserver(timeObserverToken) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - player?.seek( - to: CMTimeMake( - value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]), - timescale: 1 - ), - toleranceBefore: .zero, - toleranceAfter: .zero, - completionHandler: { _ in - self.play() - } - ) - } - - private func createMetadata() -> [AVMetadataItem] { - [] - -// let allMetadata: [AVMetadataIdentifier: Any?] = [ -// .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, -// .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, -// ] -// -// return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } - } - - private func createMetadataItem( - for identifier: AVMetadataIdentifier, - value: Any? - ) -> AVMetadataItem? { - guard let value else { return nil } - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as? AVMetadataItem - } - - private func play() { - player?.play() - - videoPlayerManager.sendStartReport() - } - - private func stop() { - player?.pause() - - videoPlayerManager.sendStopReport() - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift b/jellypig iOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift deleted file mode 100644 index 20d3f9c5..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/ChapterOverlay.swift +++ /dev/null @@ -1,191 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import CollectionHStack -import Defaults -import JellyfinAPI -import OrderedCollections -import SwiftUI -import VLCUI - -// TODO: figure out why `continuousLeadingEdge` scroll behavior has different -// insets than default continuous -extension VideoPlayer.Overlay { - - struct ChapterOverlay: View { - - @Default(.accentColor) - private var accentColor - - @Environment(\.currentOverlayType) - @Binding - private var currentOverlayType - @Environment(\.safeAreaInsets) - private var safeAreaInsets - - @EnvironmentObject - private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler - @EnvironmentObject - private var overlayTimer: TimerProxy - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - @State - private var size: CGSize = .zero - - @StateObject - private var collectionHStackProxy: CollectionHStackProxy = .init() - - var body: some View { - VStack(spacing: 0) { - - Spacer(minLength: 0) - .allowsHitTesting(false) - - HStack { - L10n.chapters.text - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.white) - .accessibility(addTraits: [.isHeader]) - - Spacer() - - Button { - if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { - collectionHStackProxy.scrollTo(id: currentChapter.unwrappedIDHashOrZero) - } - } label: { - Text(L10n.current) - .font(.title2) - .foregroundColor(accentColor) - } - } - .padding(.horizontal, safeAreaInsets.leading) - .edgePadding(.horizontal) - - CollectionHStack( - uniqueElements: viewModel.chapters, - id: \.unwrappedIDHashOrZero, - minWidth: 200 - ) { chapter in - ChapterButton(chapter: chapter) - } - .insets(horizontal: EdgeInsets.edgePadding, vertical: EdgeInsets.edgePadding) - .proxy(collectionHStackProxy) - .onChange(of: currentOverlayType) { newValue in - guard newValue == .chapters else { return } - - if let currentChapter = viewModel.chapter(from: currentProgressHandler.seconds) { - collectionHStackProxy.scrollTo(id: currentChapter, animated: false) - } - } - .trackingSize($size) - .id(size.width) - } - .background { - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black.opacity(0.4), location: 0.4), - .init(color: .black.opacity(0.9), location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .allowsHitTesting(false) - } - } - } -} - -extension VideoPlayer.Overlay.ChapterOverlay { - - struct ChapterButton: View { - - @Default(.accentColor) - private var accentColor - - @EnvironmentObject - private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler - @EnvironmentObject - private var overlayTimer: TimerProxy - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - - let chapter: ChapterInfo.FullInfo - - var body: some View { - Button { - let seconds = chapter.chapterInfo.startTimeSeconds - videoPlayerProxy.setTime(.seconds(seconds)) - - if videoPlayerManager.state != .playing { - videoPlayerProxy.play() - } - } label: { - VStack(alignment: .leading) { - ZStack { - Color.black - - ImageView(chapter.landscapeImageSources(maxWidth: 500)) - .failure { - SystemImageContentView(systemName: chapter.systemImage) - } - .aspectRatio(contentMode: .fit) - } - .overlay { - modifier(OnSizeChangedModifier { size in - if chapter.secondsRange.contains(currentProgressHandler.seconds) { - RoundedRectangle(cornerRadius: size.width * (1 / 30)) - .stroke(accentColor, lineWidth: 8) - .transition(.opacity.animation(.linear(duration: 0.1))) - .clipped() - } else { - RoundedRectangle(cornerRadius: size.width * (1 / 30)) - .stroke( - .white.opacity(0.10), - lineWidth: 1.5 - ) - .clipped() - } - }) - } - .aspectRatio(1.77, contentMode: .fill) - .posterBorder(ratio: 1 / 30, of: \.width) - .cornerRadius(ratio: 1 / 30, of: \.width) - - VStack(alignment: .leading, spacing: 5) { - Text(chapter.chapterInfo.displayTitle) - .font(.subheadline) - .fontWeight(.semibold) - .lineLimit(1) - .foregroundColor(.white) - - Text(chapter.chapterInfo.timestampLabel) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(Color(UIColor.systemBlue)) - .padding(.vertical, 2) - .padding(.horizontal, 4) - .background { - Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) - } - } - } - } - .buttonStyle(.plain) - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift deleted file mode 100644 index b235e2a9..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ActionButtons.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Foundation - -extension VideoPlayer.Overlay { - - enum ActionButtons {} -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AdvancedActionButton.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AdvancedActionButton.swift deleted file mode 100644 index d5a40d56..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AdvancedActionButton.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension VideoPlayer.Overlay.ActionButtons { - - struct Advanced: View { - - @Environment(\.aspectFilled) - @Binding - private var aspectFilled: Bool - - @EnvironmentObject - private var overlayTimer: TimerProxy - @EnvironmentObject - private var splitViewProxy: SplitContentViewProxy - - private var content: () -> any View - - var body: some View { - Button { - overlayTimer.start(5) - splitViewProxy.present() - } label: { - content().eraseToAnyView() - } - } - } -} - -extension VideoPlayer.Overlay.ActionButtons.Advanced { - - init(@ViewBuilder _ content: @escaping () -> any View) { - self.content = content - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift deleted file mode 100644 index ad9ac8cc..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AspectFillActionButton.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay.ActionButtons { - - struct AspectFill: View { - - @Environment(\.aspectFilled) - @Binding - private var aspectFilled: Bool - - @EnvironmentObject - private var overlayTimer: TimerProxy - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - - private var content: (Bool) -> any View - - var body: some View { - Button { - overlayTimer.start(5) - aspectFilled.toggle() - } label: { - content(aspectFilled).eraseToAnyView() - } - } - } -} - -extension VideoPlayer.Overlay.ActionButtons.AspectFill { - - init(@ViewBuilder _ content: @escaping (Bool) -> any View) { - self.content = content - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AudioActionButton.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AudioActionButton.swift deleted file mode 100644 index 3bb217e7..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AudioActionButton.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay.ActionButtons { - - struct Audio: View { - - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - private var content: (Bool) -> any View - - var body: some View { - Menu { - ForEach(viewModel.audioStreams.prepending(.none), id: \.index) { audioTrack in - Button { - videoPlayerManager.audioTrackIndex = audioTrack.index ?? -1 - videoPlayerProxy.setAudioTrack(.absolute(audioTrack.index ?? -1)) - } label: { - if videoPlayerManager.audioTrackIndex == audioTrack.index ?? -1 { - Label(audioTrack.displayTitle ?? .emptyDash, systemImage: "checkmark") - } else { - Text(audioTrack.displayTitle ?? .emptyDash) - } - } - } - } label: { - content(videoPlayerManager.audioTrackIndex != -1) - .eraseToAnyView() - } - } - } -} - -extension VideoPlayer.Overlay.ActionButtons.Audio { - - init(@ViewBuilder _ content: @escaping (Bool) -> any View) { - self.content = content - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift deleted file mode 100644 index 308383de..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/AutoPlayActionButton.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayer.Overlay.ActionButtons { - - struct AutoPlay: View { - - @Default(.VideoPlayer.autoPlayEnabled) - private var autoPlayEnabled - - @EnvironmentObject - private var overlayTimer: TimerProxy - - private var content: (Bool) -> any View - - var body: some View { - Button { - autoPlayEnabled.toggle() - overlayTimer.start(5) - } label: { - content(autoPlayEnabled) - .eraseToAnyView() - } - } - } -} - -extension VideoPlayer.Overlay.ActionButtons.AutoPlay { - - init(@ViewBuilder _ content: @escaping (Bool) -> any View) { - self.content = content - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift deleted file mode 100644 index c842ad64..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/ChaptersActionButton.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayer.Overlay.ActionButtons { - - struct Chapters: View { - - @Default(.VideoPlayer.autoPlayEnabled) - private var autoPlayEnabled - - @Environment(\.currentOverlayType) - @Binding - private var currentOverlayType - - @EnvironmentObject - private var overlayTimer: TimerProxy - - private var content: () -> any View - - var body: some View { - Button { - currentOverlayType = .chapters - overlayTimer.stop() - } label: { - content() - .eraseToAnyView() - } - } - } -} - -extension VideoPlayer.Overlay.ActionButtons.Chapters { - - init(@ViewBuilder _ content: @escaping () -> any View) { - self.content = content - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift deleted file mode 100644 index cb8d30b0..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayNextItemActionButton.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension VideoPlayer.Overlay.ActionButtons { - - struct PlayNextItem: View { - - @EnvironmentObject - private var overlayTimer: TimerProxy - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - - private var content: () -> any View - - var body: some View { - Button { - videoPlayerManager.selectNextViewModel() - overlayTimer.start(5) - } label: { - content() - .eraseToAnyView() - } - .disabled(videoPlayerManager.nextViewModel == nil) - .foregroundColor(videoPlayerManager.nextViewModel == nil ? .gray : .white) - } - } -} - -extension VideoPlayer.Overlay.ActionButtons.PlayNextItem { - - init(@ViewBuilder _ content: @escaping () -> any View) { - self.content = content - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift deleted file mode 100644 index 3816c4a1..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlayPreviousItemActionButton.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension VideoPlayer.Overlay.ActionButtons { - - struct PlayPreviousItem: View { - - @EnvironmentObject - private var overlayTimer: TimerProxy - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - - private var content: () -> any View - - var body: some View { - Button { - videoPlayerManager.selectPreviousViewModel() - overlayTimer.start(5) - } label: { - content() - .eraseToAnyView() - } - .disabled(videoPlayerManager.previousViewModel == nil) - .foregroundColor(videoPlayerManager.previousViewModel == nil ? .gray : .white) - } - } -} - -extension VideoPlayer.Overlay.ActionButtons.PlayPreviousItem { - - init(@ViewBuilder _ content: @escaping () -> any View) { - self.content = content - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.swift deleted file mode 100644 index 673be667..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/PlaybackSpeedActionButton.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay.ActionButtons { - - struct PlaybackSpeedMenu: View { - - @EnvironmentObject - private var overlayTimer: TimerProxy - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - - private var content: () -> any View - - var body: some View { - Menu { - ForEach(PlaybackSpeed.allCases, id: \.self) { speed in - Button { - videoPlayerManager.playbackSpeed = speed - videoPlayerProxy.setRate(.absolute(Float(speed.rawValue))) - } label: { - if speed == videoPlayerManager.playbackSpeed { - Label(speed.displayTitle, systemImage: "checkmark") - } else { - Text(speed.displayTitle) - } - } - } - } label: { - content().eraseToAnyView() - } - } - } -} - -extension VideoPlayer.Overlay.ActionButtons.PlaybackSpeedMenu { - - init(@ViewBuilder _ content: @escaping () -> any View) { - self.content = content - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleActionButton.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleActionButton.swift deleted file mode 100644 index 7829c9cd..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ActionButtons/SubtitleActionButton.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay.ActionButtons { - - struct Subtitles: View { - - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - private var content: (Bool) -> any View - - var body: some View { - Menu { - ForEach(viewModel.subtitleStreams.prepending(.none), id: \.index) { subtitleTrack in - Button { - videoPlayerManager.subtitleTrackIndex = subtitleTrack.index ?? -1 - videoPlayerProxy.setSubtitleTrack(.absolute(subtitleTrack.index ?? -1)) - } label: { - if videoPlayerManager.subtitleTrackIndex == subtitleTrack.index ?? -1 { - Label(subtitleTrack.displayTitle ?? .emptyDash, systemImage: "checkmark") - } else { - Text(subtitleTrack.displayTitle ?? .emptyDash) - } - } - } - } label: { - content(videoPlayerManager.subtitleTrackIndex != -1) - .eraseToAnyView() - } - } - } -} - -extension VideoPlayer.Overlay.ActionButtons.Subtitles { - - init(@ViewBuilder _ content: @escaping (Bool) -> any View) { - self.content = content - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift deleted file mode 100644 index 1fe8861f..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/BarActionButtons.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay { - - struct BarActionButtons: View { - - @Default(.VideoPlayer.barActionButtons) - private var barActionButtons - @Default(.VideoPlayer.menuActionButtons) - private var menuActionButtons - - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - @ViewBuilder - private var advancedButton: some View { - ActionButtons.Advanced { - Image(systemName: "gearshape.fill") - .frame(width: 45, height: 45) - .contentShape(Rectangle()) - } - } - - @ViewBuilder - private var aspectFillButton: some View { - ActionButtons.AspectFill { isAspectFilled in - Group { - if isAspectFilled { - Image(systemName: "arrow.down.right.and.arrow.up.left") - } else { - Image(systemName: "arrow.up.left.and.arrow.down.right") - } - } - .frame(width: 45, height: 45) - .contentShape(Rectangle()) - } - } - - @ViewBuilder - private var audioTrackMenu: some View { - ActionButtons.Audio { audioTrackSelected in - Group { - if audioTrackSelected { - Image(systemName: "speaker.wave.2.fill") - } else { - Image(systemName: "speaker.wave.2") - } - } - .frame(width: 45, height: 45) - .contentShape(Rectangle()) - } - } - - @ViewBuilder - private var autoPlayButton: some View { - if viewModel.item.type == .episode { - ActionButtons.AutoPlay { autoPlayEnabled in - Group { - if autoPlayEnabled { - Image(systemName: "play.circle.fill") - } else { - Image(systemName: "stop.circle") - } - } - .frame(width: 45, height: 45) - .contentShape(Rectangle()) - } - } - } - - @ViewBuilder - private var chaptersButton: some View { - if viewModel.chapters.isNotEmpty { - ActionButtons.Chapters { - Image(systemName: "list.dash") - .frame(width: 45, height: 45) - .contentShape(Rectangle()) - } - } - } - - @ViewBuilder - private var playbackSpeedMenu: some View { - ActionButtons.PlaybackSpeedMenu { - Image(systemName: "speedometer") - .frame(width: 45, height: 45) - .contentShape(Rectangle()) - } - } - - @ViewBuilder - private var playNextItemButton: some View { - if viewModel.item.type == .episode { - ActionButtons.PlayNextItem { - Image(systemName: "chevron.right.circle") - .frame(width: 45, height: 45) - .contentShape(Rectangle()) - } - } - } - - @ViewBuilder - private var playPreviousItemButton: some View { - if viewModel.item.type == .episode { - ActionButtons.PlayPreviousItem { - Image(systemName: "chevron.left.circle") - .frame(width: 45, height: 45) - .contentShape(Rectangle()) - } - } - } - - @ViewBuilder - private var subtitleTrackMenu: some View { - ActionButtons.Subtitles { subtitleTrackSelected in - Group { - if subtitleTrackSelected { - Image(systemName: "captions.bubble.fill") - } else { - Image(systemName: "captions.bubble") - } - } - .frame(width: 45, height: 45) - .contentShape(Rectangle()) - } - } - - var body: some View { - HStack(spacing: 0) { - ForEach(barActionButtons) { actionButton in - switch actionButton { -// case .advanced: -// advancedButton - case .aspectFill: - aspectFillButton - case .audio: - audioTrackMenu - case .autoPlay: - autoPlayButton - case .chapters: - chaptersButton - case .playbackSpeed: - playbackSpeedMenu - case .playNextItem: - playNextItemButton - case .playPreviousItem: - playPreviousItemButton - case .subtitles: - subtitleTrackMenu - } - } - - if menuActionButtons.isNotEmpty { - OverlayMenu() - } - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift deleted file mode 100644 index d98c86e2..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/BottomBarView.swift +++ /dev/null @@ -1,166 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay { - - struct BottomBarView: View { - - @Default(.VideoPlayer.Overlay.chapterSlider) - private var chapterSlider - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - @Default(.VideoPlayer.Overlay.playbackButtonType) - private var playbackButtonType - @Default(.VideoPlayer.Overlay.sliderType) - private var sliderType - @Default(.VideoPlayer.Overlay.timestampType) - private var timestampType - - @Environment(\.currentOverlayType) - @Binding - private var currentOverlayType - - @Environment(\.isPresentingOverlay) - @Binding - private var isPresentingOverlay - @Environment(\.isScrubbing) - @Binding - private var isScrubbing: Bool - - @EnvironmentObject - private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler - @EnvironmentObject - private var overlayTimer: TimerProxy - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - @State - private var currentChapter: ChapterInfo.FullInfo? - - @ViewBuilder - private var capsuleSlider: some View { - CapsuleSlider(progress: $currentProgressHandler.scrubbedProgress) - .isEditing(_isScrubbing.wrappedValue) - .trackMask { - if chapterSlider && viewModel.chapters.isNotEmpty { - ChapterTrack() - .clipShape(Capsule()) - } else { - Color.white - } - } - .bottomContent { - Group { - switch timestampType { - case .split: - SplitTimeStamp() - case .compact: - CompactTimeStamp() - } - } - .padding(5) - } - .leadingContent { - if playbackButtonType == .compact { - SmallPlaybackButtons() - .padding(.trailing) - .disabled(isScrubbing) - } - } - .frame(height: 50) - } - - @ViewBuilder - private var thumbSlider: some View { - ThumbSlider(progress: $currentProgressHandler.scrubbedProgress) - .isEditing(_isScrubbing.wrappedValue) - .trackMask { - if chapterSlider && viewModel.chapters.isNotEmpty { - ChapterTrack() - .clipShape(Capsule()) - } else { - Color.white - } - } - .bottomContent { - Group { - switch timestampType { - case .split: - SplitTimeStamp() - case .compact: - CompactTimeStamp() - } - } - .padding(5) - } - .leadingContent { - if playbackButtonType == .compact { - SmallPlaybackButtons() - .padding(.trailing) - .disabled(isScrubbing) - } - } - } - - var body: some View { - VStack(spacing: 0) { - HStack { - if chapterSlider, let currentChapter { - Button { - currentOverlayType = .chapters - overlayTimer.stop() - } label: { - HStack { - Text(currentChapter.displayTitle) - .monospacedDigit() - - Image(systemName: "chevron.right") - } - .foregroundColor(.white) - .font(.subheadline.weight(.medium)) - } - .disabled(isScrubbing) - } - - Spacer() - } - .padding(.leading, 5) - .padding(.bottom, 15) - - Group { - switch sliderType { - case .capsule: capsuleSlider - case .thumb: thumbSlider - } - } - } - .onChange(of: currentProgressHandler.scrubbedSeconds) { newValue in - guard chapterSlider else { return } - let newChapter = viewModel.chapter(from: newValue) - if newChapter != currentChapter { - if isScrubbing { - UIDevice.impact(.light) - } - - self.currentChapter = newChapter - } - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ChapterTrack.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/ChapterTrack.swift deleted file mode 100644 index d34cf044..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/ChapterTrack.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension VideoPlayer.Overlay { - - struct ChapterTrack: View { - - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - @State - private var width: CGFloat = 0 - - private func maxWidth(for chapter: ChapterInfo.FullInfo) -> CGFloat { - width * CGFloat(chapter.secondsRange.count) / CGFloat(viewModel.item.runTimeSeconds) - } - - var body: some View { - HStack(spacing: 0) { - ForEach(viewModel.chapters, id: \.self) { chapter in - HStack(spacing: 0) { - if chapter != viewModel.chapters.first { - Color.clear - .frame(width: 1.5) - } - - Color.white - } - .frame(maxWidth: maxWidth(for: chapter)) - } - } - .frame(maxWidth: .infinity) - .onSizeChanged { newSize in - width = newSize.width - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift deleted file mode 100644 index 73cdee52..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/OverlayMenu.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay { - - struct OverlayMenu: View { - - @Default(.VideoPlayer.menuActionButtons) - private var menuActionButtons - - @EnvironmentObject - private var splitContentViewProxy: SplitContentViewProxy - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - @ViewBuilder - private var advancedButton: some View { - Button { - splitContentViewProxy.present() - } label: { - HStack { - Image(systemName: "gearshape.fill") - - Text(L10n.advanced) - } - } - } - - @ViewBuilder - private var aspectFillButton: some View { - ActionButtons.AspectFill { isAspectFilled in - HStack { - if isAspectFilled { - Image(systemName: "arrow.down.right.and.arrow.up.left") - } else { - Image(systemName: "arrow.up.left.and.arrow.down.right") - } - - Text(L10n.aspectFill) - } - } - } - - @ViewBuilder - private var audioTrackMenu: some View { - ActionButtons.Audio { audioTrackSelected in - HStack { - if audioTrackSelected { - Image(systemName: "speaker.wave.2.fill") - } else { - Image(systemName: "speaker.wave.2") - } - - L10n.audio.text - } - } - } - - @ViewBuilder - private var autoPlayButton: some View { - if viewModel.item.type == .episode { - ActionButtons.AutoPlay { autoPlayEnabled in - HStack { - if autoPlayEnabled { - Image(systemName: "play.circle.fill") - } else { - Image(systemName: "stop.circle") - } - - L10n.autoPlay.text - } - } - } - } - - @ViewBuilder - private var chaptersButton: some View { - if viewModel.chapters.isNotEmpty { - ActionButtons.Chapters { - HStack { - Image(systemName: "list.dash") - - L10n.chapters.text - } - } - } - } - - @ViewBuilder - private var playbackSpeedMenu: some View { - ActionButtons.PlaybackSpeedMenu { - HStack { - Image(systemName: "speedometer") - - L10n.playbackSpeed.text - } - } - } - - @ViewBuilder - private var playNextItemButton: some View { - if viewModel.item.type == .episode { - ActionButtons.PlayNextItem { - HStack { - Image(systemName: "chevron.right.circle") - - Text(L10n.playNextItem) - } - } - } - } - - @ViewBuilder - private var playPreviousItemButton: some View { - if viewModel.item.type == .episode { - ActionButtons.PlayPreviousItem { - HStack { - Image(systemName: "chevron.left.circle") - - Text(L10n.playPreviousItem) - } - } - } - } - - @ViewBuilder - private var subtitleTrackMenu: some View { - ActionButtons.Subtitles { subtitleTrackSelected in - HStack { - if subtitleTrackSelected { - Image(systemName: "captions.bubble.fill") - } else { - Image(systemName: "captions.bubble") - } - - L10n.subtitles.text - } - } - } - - var body: some View { - Menu { - ForEach(menuActionButtons) { actionButton in - switch actionButton { -// case .advanced: -// advancedButton - case .aspectFill: - aspectFillButton - case .audio: - audioTrackMenu - case .autoPlay: - autoPlayButton - case .chapters: - chaptersButton - case .playbackSpeed: - playbackSpeedMenu - case .playNextItem: - playNextItemButton - case .playPreviousItem: - playPreviousItemButton - case .subtitles: - subtitleTrackMenu - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - .frame(width: 50, height: 50) - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/PlaybackButtons/LargePlaybackButtons.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/PlaybackButtons/LargePlaybackButtons.swift deleted file mode 100644 index 8a7f6933..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/PlaybackButtons/LargePlaybackButtons.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay { - - struct LargePlaybackButtons: View { - - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - @Default(.VideoPlayer.showJumpButtons) - private var showJumpButtons - - @EnvironmentObject - private var timerProxy: TimerProxy - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - - @ViewBuilder - private var jumpBackwardButton: some View { - Button { - videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) - timerProxy.start(5) - } label: { - Image(systemName: jumpBackwardLength.backwardImageLabel) - .font(.system(size: 36, weight: .regular, design: .default)) - .padding() - .contentShape(Rectangle()) - } - .contentShape(Rectangle()) - .buttonStyle(ScalingButtonStyle(scale: 0.9)) - } - - @ViewBuilder - private var playButton: some View { - Button { - switch videoPlayerManager.state { - case .playing: - videoPlayerProxy.pause() - default: - videoPlayerProxy.play() - } - timerProxy.start(5) - } label: { - Group { - switch videoPlayerManager.state { - case .stopped, .paused: - Image(systemName: "play.fill") - case .playing: - Image(systemName: "pause.fill") - default: - ProgressView() - .scaleEffect(2) - } - } - .font(.system(size: 56, weight: .bold, design: .default)) - .padding() - .transition(.opacity) - .contentShape(Rectangle()) - } - .contentShape(Rectangle()) - .buttonStyle(ScalingButtonStyle(scale: 0.9)) - } - - @ViewBuilder - private var jumpForwardButton: some View { - Button { - videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) - timerProxy.start(5) - } label: { - Image(systemName: jumpForwardLength.forwardImageLabel) - .font(.system(size: 36, weight: .regular, design: .default)) - .padding() - .contentShape(Rectangle()) - } - .contentShape(Rectangle()) - .buttonStyle(ScalingButtonStyle(scale: 0.9)) - } - - var body: some View { - HStack(spacing: 0) { - - Spacer(minLength: 100) - - if showJumpButtons { - jumpBackwardButton - } - - playButton - .frame(minWidth: 100, maxWidth: 300) - - if showJumpButtons { - jumpForwardButton - } - - Spacer(minLength: 100) - } - .tint(Color.white) - .foregroundColor(Color.white) - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/PlaybackButtons/SmallPlaybackButtons.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/PlaybackButtons/SmallPlaybackButtons.swift deleted file mode 100644 index 9bb3ac8d..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/PlaybackButtons/SmallPlaybackButtons.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay { - - struct SmallPlaybackButtons: View { - - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - @Default(.VideoPlayer.showJumpButtons) - private var showJumpButtons - - @EnvironmentObject - private var timerProxy: TimerProxy - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - - @ViewBuilder - private var jumpBackwardButton: some View { - Button { - videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) - timerProxy.start(5) - } label: { - Image(systemName: jumpBackwardLength.backwardImageLabel) - .font(.system(size: 24, weight: .bold, design: .default)) - } - .contentShape(Rectangle()) - } - - @ViewBuilder - private var playButton: some View { - Button { - switch videoPlayerManager.state { - case .playing: - videoPlayerProxy.pause() - default: - videoPlayerProxy.play() - } - timerProxy.start(5) - } label: { - Group { - switch videoPlayerManager.state { - case .stopped, .paused: - Image(systemName: "play.fill") - case .playing: - Image(systemName: "pause.fill") - default: - ProgressView() - } - } - .font(.system(size: 28, weight: .bold, design: .default)) - .frame(width: 50, height: 50) - } - .contentShape(Rectangle()) - } - - @ViewBuilder - private var jumpForwardButton: some View { - Button { - videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) - timerProxy.start(5) - } label: { - Image(systemName: jumpForwardLength.forwardImageLabel) - .font(.system(size: 24, weight: .bold, design: .default)) - } - .contentShape(Rectangle()) - } - - var body: some View { - HStack(spacing: 15) { - - if showJumpButtons { - jumpBackwardButton - } - - playButton - - if showJumpButtons { - jumpForwardButton - } - } - .tint(Color.white) - .foregroundColor(Color.white) - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/Timestamp/CompactTimeStamp.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/Timestamp/CompactTimeStamp.swift deleted file mode 100644 index f84baf46..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/Timestamp/CompactTimeStamp.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayer.Overlay { - - struct CompactTimeStamp: View { - - @Default(.VideoPlayer.Overlay.showCurrentTimeWhileScrubbing) - private var showCurrentTimeWhileScrubbing - @Default(.VideoPlayer.Overlay.trailingTimestampType) - private var trailingTimestampType - - @EnvironmentObject - private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - @Environment(\.isScrubbing) - @Binding - private var isScrubbing: Bool - - @ViewBuilder - private var leadingTimestamp: some View { - Button { - switch trailingTimestampType { - case .timeLeft: - trailingTimestampType = .totalTime - case .totalTime: - trailingTimestampType = .timeLeft - } - } label: { - HStack(spacing: 2) { - - Text(currentProgressHandler.scrubbedSeconds.timeLabel) - .foregroundColor(.white) - - Text("/") - .foregroundColor(Color(UIColor.lightText)) - - switch trailingTimestampType { - case .timeLeft: - Text((viewModel.item.runTimeSeconds - currentProgressHandler.scrubbedSeconds).timeLabel.prepending("-")) - .foregroundColor(Color(UIColor.lightText)) - case .totalTime: - Text(viewModel.item.runTimeSeconds.timeLabel) - .foregroundColor(Color(UIColor.lightText)) - } - } - } - } - - @ViewBuilder - private var trailingTimestamp: some View { - HStack(spacing: 2) { - - Text(currentProgressHandler.seconds.timeLabel) - - Text("/") - - Text((viewModel.item.runTimeSeconds - currentProgressHandler.seconds).timeLabel) - } - .foregroundColor(Color(UIColor.lightText)) - } - - var body: some View { - HStack { - leadingTimestamp - - Spacer() - - if isScrubbing && showCurrentTimeWhileScrubbing { - trailingTimestamp - } - } - .monospacedDigit() - .font(.caption) - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/Timestamp/SplitTimestamp.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/Timestamp/SplitTimestamp.swift deleted file mode 100644 index 0d99aaa1..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/Timestamp/SplitTimestamp.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayer.Overlay { - - struct SplitTimeStamp: View { - - @Default(.VideoPlayer.Overlay.showCurrentTimeWhileScrubbing) - private var showCurrentTimeWhileScrubbing - @Default(.VideoPlayer.Overlay.trailingTimestampType) - private var trailingTimestampType - - @EnvironmentObject - private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - @Environment(\.isScrubbing) - @Binding - private var isScrubbing: Bool - - @ViewBuilder - private var leadingTimestamp: some View { - HStack(spacing: 2) { - - Text(currentProgressHandler.scrubbedSeconds.timeLabel) - .foregroundColor(.white) - - if isScrubbing && showCurrentTimeWhileScrubbing { - Text("/") - .foregroundColor(Color(UIColor.lightText)) - - Text(currentProgressHandler.seconds.timeLabel) - .foregroundColor(Color(UIColor.lightText)) - } - } - } - - @ViewBuilder - private var trailingTimestamp: some View { - HStack(spacing: 2) { - if isScrubbing && showCurrentTimeWhileScrubbing { - Text((viewModel.item.runTimeSeconds - currentProgressHandler.seconds).timeLabel.prepending("-")) - .foregroundColor(Color(UIColor.lightText)) - - Text("/") - .foregroundColor(Color(UIColor.lightText)) - } - - switch trailingTimestampType { - case .timeLeft: - Text((viewModel.item.runTimeSeconds - currentProgressHandler.scrubbedSeconds).timeLabel.prepending("-")) - .foregroundColor(.white) - case .totalTime: - Text(viewModel.item.runTimeSeconds.timeLabel) - .foregroundColor(.white) - } - } - } - - var body: some View { - Button { - switch trailingTimestampType { - case .timeLeft: - trailingTimestampType = .totalTime - case .totalTime: - trailingTimestampType = .timeLeft - } - } label: { - HStack { - leadingTimestamp - - Spacer() - - trailingTimestamp - } - .monospacedDigit() - .font(.caption) - .lineLimit(1) - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Components/TopBarView.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Components/TopBarView.swift deleted file mode 100644 index 6e6c825d..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Components/TopBarView.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import Stinsen -import SwiftUI -import VLCUI - -extension VideoPlayer.Overlay { - - struct TopBarView: View { - - @EnvironmentObject - private var router: VideoPlayerCoordinator.Router - @EnvironmentObject - private var splitContentViewProxy: SplitContentViewProxy - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - @EnvironmentObject - private var viewModel: VideoPlayerViewModel - - var body: some View { - VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 0) { - HStack(alignment: .center) { - Button { - videoPlayerProxy.stop() - router.dismissCoordinator() - } label: { - Image(systemName: "xmark") - .padding() - } - .contentShape(Rectangle()) - .buttonStyle(ScalingButtonStyle(scale: 0.8)) - - Text(viewModel.item.displayTitle) - .font(.title3) - .fontWeight(.bold) - .lineLimit(1) - .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in - dimensions[.leading] - } - - Spacer() - - VideoPlayer.Overlay.BarActionButtons() - .buttonStyle(ScalingButtonStyle(scale: 0.8)) - } - .font(.system(size: 24)) - .tint(Color.white) - .foregroundColor(Color.white) - -// if let subtitle = viewModel.item.subtitle { -// Text(subtitle) -// .font(.subheadline) -// .foregroundColor(.white) -// .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in -// dimensions[.leading] -// } -// .offset(y: -10) -// } - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/MainOverlay.swift b/jellypig iOS/Views/VideoPlayer/Overlays/MainOverlay.swift deleted file mode 100644 index 98576740..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/MainOverlay.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -extension VideoPlayer { - - struct MainOverlay: View { - - @Default(.VideoPlayer.Overlay.playbackButtonType) - private var playbackButtonType - - @Environment(\.currentOverlayType) - @Binding - private var currentOverlayType - @Environment(\.isPresentingOverlay) - @Binding - private var isPresentingOverlay - @Environment(\.isScrubbing) - @Binding - private var isScrubbing: Bool - @Environment(\.safeAreaInsets) - private var safeAreaInsets - - @EnvironmentObject - private var splitContentViewProxy: SplitContentViewProxy - - @StateObject - private var overlayTimer: TimerProxy = .init() - - var body: some View { - ZStack { - VStack { - Overlay.TopBarView() - .if(UIDevice.hasNotch) { view in - view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) - .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) - } - .if(UIDevice.isPad) { view in - view.padding(.top) - .padding(.horizontal) - } - .background { - LinearGradient( - stops: [ - .init(color: .black.opacity(0.9), location: 0), - .init(color: .clear, location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .visible(playbackButtonType == .compact) - } - .visible(!isScrubbing && isPresentingOverlay) - - Spacer() - .allowsHitTesting(false) - - Overlay.BottomBarView() - .if(UIDevice.hasNotch) { view in - view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) - .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) - } - .if(UIDevice.isPad) { view in - view.padding(.bottom) - .padding(.horizontal) - } - .background { - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black.opacity(0.5), location: 0.5), - .init(color: .black.opacity(0.5), location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .visible(isScrubbing || playbackButtonType == .compact) - } - .background { - Color.clear - .allowsHitTesting(true) - .contentShape(Rectangle()) - .allowsHitTesting(true) - } - .visible(isScrubbing || isPresentingOverlay) - } - - if playbackButtonType == .large { - Overlay.LargePlaybackButtons() - .visible(!isScrubbing && isPresentingOverlay) - } - } - .environmentObject(overlayTimer) - .background { - Color.black - .opacity(!isScrubbing && playbackButtonType == .large && isPresentingOverlay ? 0.5 : 0) - .allowsHitTesting(false) - } - .animation(.linear(duration: 0.1), value: isScrubbing) - .onChange(of: isPresentingOverlay) { newValue in - guard newValue, !isScrubbing else { return } - overlayTimer.start(5) - } - .onChange(of: isScrubbing) { newValue in - if newValue { - overlayTimer.stop() - } else { - overlayTimer.start(5) - } - } - .onChange(of: overlayTimer.isActive) { newValue in - guard !newValue, !isScrubbing else { return } - - withAnimation(.linear(duration: 0.3)) { - isPresentingOverlay = false - } - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/Overlays/Overlay.swift b/jellypig iOS/Views/VideoPlayer/Overlays/Overlay.swift deleted file mode 100644 index a6089fe3..00000000 --- a/jellypig iOS/Views/VideoPlayer/Overlays/Overlay.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension VideoPlayer { - - struct Overlay: View { - - @Environment(\.isPresentingOverlay) - @Binding - private var isPresentingOverlay - - @State - private var currentOverlayType: VideoPlayer.OverlayType = .main - - var body: some View { - ZStack { - - MainOverlay() - .visible(currentOverlayType == .main) - - ChapterOverlay() - .visible(currentOverlayType == .chapters) - } - .animation(.linear(duration: 0.1), value: currentOverlayType) - .environment(\.currentOverlayType, $currentOverlayType) - .onChange(of: isPresentingOverlay) { newValue in - guard newValue else { return } - currentOverlayType = .main - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/VideoPlayer+Actions.swift b/jellypig iOS/Views/VideoPlayer/VideoPlayer+Actions.swift deleted file mode 100644 index cbb02019..00000000 --- a/jellypig iOS/Views/VideoPlayer/VideoPlayer+Actions.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -extension VideoPlayer { - - enum Action { - - // MARK: Aspect Fill - - func aspectFill( - state: UIGestureRecognizer.State, - unitPoint: UnitPoint, - scale: CGFloat - ) {} - } -} diff --git a/jellypig iOS/Views/VideoPlayer/VideoPlayer+KeyCommands.swift b/jellypig iOS/Views/VideoPlayer/VideoPlayer+KeyCommands.swift deleted file mode 100644 index 304e491a..00000000 --- a/jellypig iOS/Views/VideoPlayer/VideoPlayer+KeyCommands.swift +++ /dev/null @@ -1,227 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import PreferencesView -import SwiftUI -import VLCUI - -extension View { - - func videoPlayerKeyCommands( - gestureStateHandler: VideoPlayer.GestureStateHandler, - updateViewProxy: UpdateViewProxy - ) -> some View { - modifier( - VideoPlayerKeyCommandsModifier( - gestureStateHandler: gestureStateHandler, - updateViewProxy: updateViewProxy - ) - ) - } -} - -struct VideoPlayerKeyCommandsModifier: ViewModifier { - - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - - @Environment(\.aspectFilled) - private var isAspectFilled - - @EnvironmentObject - private var videoPlayerManager: VideoPlayerManager - @EnvironmentObject - private var videoPlayerProxy: VLCVideoPlayer.Proxy - - let gestureStateHandler: VideoPlayer.GestureStateHandler - let updateViewProxy: UpdateViewProxy - - func body(content: Content) -> some View { - content.keyCommands { - - // MARK: play/pause - - KeyCommandAction( - title: L10n.playAndPause, - input: " " - ) { - if videoPlayerManager.state == .playing { - videoPlayerManager.proxy.pause() - updateViewProxy.present(systemName: "pause.fill", title: L10n.pause) - } else { - videoPlayerManager.proxy.play() - updateViewProxy.present(systemName: "play.fill", title: L10n.play) - } - } - - // MARK: jump forward - - KeyCommandAction( - title: L10n.jumpForward, - input: UIKeyCommand.inputRightArrow - ) { - if gestureStateHandler.jumpForwardKeyPressActive { - gestureStateHandler.jumpForwardKeyPressAmount += 1 - gestureStateHandler.jumpForwardKeyPressWorkItem?.cancel() - - videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) - - let task = DispatchWorkItem { - gestureStateHandler.jumpForwardKeyPressActive = false - gestureStateHandler.jumpForwardKeyPressAmount = 0 - } - - gestureStateHandler.jumpForwardKeyPressWorkItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) - } else { - gestureStateHandler.jumpForwardKeyPressActive = true - gestureStateHandler.jumpForwardKeyPressAmount += 1 - - videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) - - let task = DispatchWorkItem { - gestureStateHandler.jumpForwardKeyPressActive = false - gestureStateHandler.jumpForwardKeyPressAmount = 0 - } - - gestureStateHandler.jumpForwardKeyPressWorkItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) - } - } - - // MARK: jump backward - - KeyCommandAction( - title: L10n.jumpBackward, - input: UIKeyCommand.inputLeftArrow - ) { - if gestureStateHandler.jumpBackwardKeyPressActive { - gestureStateHandler.jumpBackwardKeyPressAmount += 1 - gestureStateHandler.jumpBackwardKeyPressWorkItem?.cancel() - - videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) - - let task = DispatchWorkItem { - gestureStateHandler.jumpBackwardKeyPressActive = false - gestureStateHandler.jumpBackwardKeyPressAmount = 0 - } - - gestureStateHandler.jumpBackwardKeyPressWorkItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) - } else { - gestureStateHandler.jumpBackwardKeyPressActive = true - gestureStateHandler.jumpBackwardKeyPressAmount += 1 - - videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) - - let task = DispatchWorkItem { - gestureStateHandler.jumpBackwardKeyPressActive = false - gestureStateHandler.jumpBackwardKeyPressAmount = 0 - } - - gestureStateHandler.jumpBackwardKeyPressWorkItem = task - - DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) - } - } - - // MARK: aspect fill - - KeyCommandAction( - title: L10n.aspectFill, - input: "f", - modifierFlags: .command - ) { - DispatchQueue.main.async { - isAspectFilled.wrappedValue.toggle() - } - } - - // MARK: decrease playback speed - - KeyCommandAction( - title: L10n.decreasePlaybackSpeed, - input: "[", - modifierFlags: .command - ) { - let clampedPlaybackSpeed = clamp( - videoPlayerManager.playbackSpeed.rawValue - 0.25, - min: 0.25, - max: 2.0 - ) - - let newPlaybackSpeed = PlaybackSpeed(rawValue: clampedPlaybackSpeed) ?? .one - videoPlayerManager.playbackSpeed = newPlaybackSpeed - videoPlayerManager.proxy.setRate(.absolute(Float(newPlaybackSpeed.rawValue))) - - updateViewProxy.present(systemName: "speedometer", title: newPlaybackSpeed.rawValue.rateLabel) - } - - // MARK: increase playback speed - - KeyCommandAction( - title: L10n.increasePlaybackSpeed, - input: "]", - modifierFlags: .command - ) { - let clampedPlaybackSpeed = clamp( - videoPlayerManager.playbackSpeed.rawValue + 0.25, - min: 0.25, - max: 2.0 - ) - - let newPlaybackSpeed = PlaybackSpeed(rawValue: clampedPlaybackSpeed) ?? .one - videoPlayerManager.playbackSpeed = newPlaybackSpeed - videoPlayerManager.proxy.setRate(.absolute(Float(newPlaybackSpeed.rawValue))) - - updateViewProxy.present(systemName: "speedometer", title: newPlaybackSpeed.rawValue.rateLabel) - } - - // MARK: reset playback speed - - KeyCommandAction( - title: L10n.resetPlaybackSpeed, - input: "\\", - modifierFlags: .command - ) { - let newPlaybackSpeed = PlaybackSpeed.one - - videoPlayerManager.playbackSpeed = newPlaybackSpeed - videoPlayerManager.proxy.setRate(.absolute(Float(newPlaybackSpeed.rawValue))) - - updateViewProxy.present(systemName: "speedometer", title: newPlaybackSpeed.rawValue.rateLabel) - } - - // MARK: next item - - KeyCommandAction( - title: L10n.nextItem, - input: UIKeyCommand.inputRightArrow, - modifierFlags: .command - ) { - videoPlayerManager.selectNextViewModel() - } - - // MARK: previous item - - KeyCommandAction( - title: L10n.previousItem, - input: UIKeyCommand.inputLeftArrow, - modifierFlags: .command - ) { - videoPlayerManager.selectPreviousViewModel() - } - } - } -} diff --git a/jellypig iOS/Views/VideoPlayer/VideoPlayer.swift b/jellypig iOS/Views/VideoPlayer/VideoPlayer.swift deleted file mode 100644 index 861c7d9d..00000000 --- a/jellypig iOS/Views/VideoPlayer/VideoPlayer.swift +++ /dev/null @@ -1,590 +0,0 @@ -// -// 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) 2025 Jellyfin & Jellyfin Contributors -// - -import Defaults -import JellyfinAPI -import MediaPlayer -import Stinsen -import SwiftUI -import VLCUI - -// TODO: organize -// TODO: localization necessary for toast text? -// TODO: entire gesture layer should be separate - -struct VideoPlayer: View { - - enum OverlayType { - case main - case chapters - } - - @Environment(\.scenePhase) - private var scenePhase - - class GestureStateHandler { - - var beganPanWithOverlay: Bool = false - var beginningPanProgress: CGFloat = 0 - var beginningHorizontalPanUnit: CGFloat = 0 - - var beginningAudioOffset: Int = 0 - var beginningBrightnessValue: CGFloat = 0 - var beginningPlaybackSpeed: Double = 0 - var beginningSubtitleOffset: Int = 0 - var beginningVolumeValue: Float = 0 - - var jumpBackwardKeyPressActive: Bool = false - var jumpBackwardKeyPressWorkItem: DispatchWorkItem? - var jumpBackwardKeyPressAmount: Int = 0 - - var jumpForwardKeyPressActive: Bool = false - var jumpForwardKeyPressWorkItem: DispatchWorkItem? - var jumpForwardKeyPressAmount: Int = 0 - } - - @Default(.VideoPlayer.jumpBackwardLength) - private var jumpBackwardLength - @Default(.VideoPlayer.jumpForwardLength) - private var jumpForwardLength - - @Default(.VideoPlayer.Gesture.horizontalPanGesture) - private var horizontalPanGesture - @Default(.VideoPlayer.Gesture.horizontalSwipeGesture) - private var horizontalSwipeGesture - @Default(.VideoPlayer.Gesture.longPressGesture) - private var longPressGesture - @Default(.VideoPlayer.Gesture.multiTapGesture) - private var multiTapGesture - @Default(.VideoPlayer.Gesture.doubleTouchGesture) - private var doubleTouchGesture - @Default(.VideoPlayer.Gesture.pinchGesture) - private var pinchGesture - @Default(.VideoPlayer.Gesture.verticalPanGestureLeft) - private var verticalGestureLeft - @Default(.VideoPlayer.Gesture.verticalPanGestureRight) - private var verticalGestureRight - - @Default(.VideoPlayer.Subtitle.subtitleColor) - private var subtitleColor - @Default(.VideoPlayer.Subtitle.subtitleFontName) - private var subtitleFontName - @Default(.VideoPlayer.Subtitle.subtitleSize) - private var subtitleSize - - @EnvironmentObject - private var router: VideoPlayerCoordinator.Router - - @ObservedObject - private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler - @StateObject - private var splitContentViewProxy: SplitContentViewProxy = .init() - @ObservedObject - private var videoPlayerManager: VideoPlayerManager - - @State - private var audioOffset: Int = 0 - @State - private var isAspectFilled: Bool = false - @State - private var isGestureLocked: Bool = false - @State - private var isPresentingOverlay: Bool = false - @State - private var isScrubbing: Bool = false - @State - private var playbackSpeed: Double = 1 - @State - private var subtitleOffset: Int = 0 - - private let gestureStateHandler: GestureStateHandler = .init() - private let updateViewProxy: UpdateViewProxy = .init() - - @ViewBuilder - private var playerView: some View { - SplitContentView(splitContentWidth: 400) - .proxy(splitContentViewProxy) - .content { - ZStack { - VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration) - .proxy(videoPlayerManager.proxy) - .onTicksUpdated { ticks, information in - - let newSeconds = ticks / 1000 - let newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) - currentProgressHandler.progress = newProgress - currentProgressHandler.seconds = newSeconds - - guard !isScrubbing else { return } - currentProgressHandler.scrubbedProgress = newProgress - - videoPlayerManager.onTicksUpdated( - ticks: ticks, - playbackInformation: information - ) - } - .onStateUpdated { state, _ in - - videoPlayerManager.onStateUpdated(newState: state) - - if state == .ended { - if let _ = videoPlayerManager.nextViewModel, - Defaults[.VideoPlayer.autoPlayEnabled] - { - videoPlayerManager.selectNextViewModel() - } else { - router.dismissCoordinator() - } - } - } - - GestureView() - .onHorizontalPan { - handlePan(action: horizontalPanGesture, state: $0, point: $1.x, velocity: $2, translation: $3) - } - .onHorizontalSwipe(translation: 100, velocity: 1500, sameSwipeDirectionTimeout: 1, handleHorizontalSwipe) - .onLongPress(minimumDuration: 2, handleLongPress) - .onPinch(handlePinchGesture) - .onTap(samePointPadding: 10, samePointTimeout: 0.7, handleTapGesture) - .onDoubleTouch(handleDoubleTouchGesture) - .onVerticalPan { - if $1.x <= 0.5 { - handlePan(action: verticalGestureLeft, state: $0, point: -$1.y, velocity: $2, translation: $3) - } else { - handlePan(action: verticalGestureRight, state: $0, point: -$1.y, velocity: $2, translation: $3) - } - } - - VideoPlayer.Overlay() - } - } - .splitContent { - // Wrapped due to navigation controller popping due to published changes - WrappedView { - NavigationViewCoordinator(PlaybackSettingsCoordinator()).view() - } - .cornerRadius(20, corners: [.topLeft, .bottomLeft]) - .environmentObject(splitContentViewProxy) - .environmentObject(videoPlayerManager) - .environmentObject(videoPlayerManager.currentViewModel) - .environment(\.audioOffset, $audioOffset) - .environment(\.subtitleOffset, $subtitleOffset) - } - .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in - guard !newValue.isNaN && !newValue.isInfinite else { - return - } - DispatchQueue.main.async { - videoPlayerManager.currentProgressHandler - .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) - } - } - .overlay(alignment: .top) { - UpdateView(proxy: updateViewProxy) - .padding(.top) - } - .videoPlayerKeyCommands( - gestureStateHandler: gestureStateHandler, - updateViewProxy: updateViewProxy - ) - .environmentObject(splitContentViewProxy) - .environmentObject(videoPlayerManager) - .environmentObject(videoPlayerManager.currentProgressHandler) - .environmentObject(videoPlayerManager.currentViewModel!) - .environmentObject(videoPlayerManager.proxy) - .environment(\.aspectFilled, $isAspectFilled) - .environment(\.isPresentingOverlay, $isPresentingOverlay) - .environment(\.isScrubbing, $isScrubbing) - .environment(\.playbackSpeed, $playbackSpeed) - } - - var body: some View { - Group { - if let _ = videoPlayerManager.currentViewModel { - playerView - } else { - LoadingView() - .transition(.opacity) - } - } - .navigationBarHidden(true) - .statusBar(hidden: true) - .ignoresSafeArea() - .onChange(of: audioOffset) { newValue in - videoPlayerManager.proxy.setAudioDelay(.ticks(newValue)) - } - .onChange(of: isAspectFilled) { newValue in - UIView.animate(withDuration: 0.2) { - videoPlayerManager.proxy.aspectFill(newValue ? 1 : 0) - } - } - .onChange(of: isGestureLocked) { newValue in - if newValue { - updateViewProxy.present(systemName: "lock.fill", title: L10n.gesturesLocked) - } else { - updateViewProxy.present(systemName: "lock.open.fill", title: L10n.gesturesUnlocked) - } - } - .onChange(of: isScrubbing) { newValue in - guard !newValue else { return } - videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds)) - } - .onChange(of: subtitleColor) { newValue in - videoPlayerManager.proxy.setSubtitleColor(.absolute(newValue.uiColor)) - } - .onChange(of: subtitleFontName) { newValue in - videoPlayerManager.proxy.setSubtitleFont(newValue) - } - .onChange(of: subtitleOffset) { newValue in - videoPlayerManager.proxy.setSubtitleDelay(.ticks(newValue)) - } - .onChange(of: subtitleSize) { newValue in - videoPlayerManager.proxy.setSubtitleSize(.absolute(24 - newValue)) - } - .onChange(of: videoPlayerManager.currentViewModel) { newViewModel in - guard let newViewModel else { return } - - videoPlayerManager.proxy.playNewMedia(newViewModel.vlcVideoPlayerConfiguration) - - isAspectFilled = false - audioOffset = 0 - subtitleOffset = 0 - } - .onScenePhase(.active) { - if Defaults[.VideoPlayer.Transition.playOnActive] { - videoPlayerManager.proxy.play() - } - } - .onScenePhase(.background) { - if Defaults[.VideoPlayer.Transition.pauseOnBackground] { - videoPlayerManager.proxy.pause() - } - } - } -} - -extension VideoPlayer { - - init(manager: VideoPlayerManager) { - self.init( - currentProgressHandler: manager.currentProgressHandler, - videoPlayerManager: manager - ) - } -} - -// MARK: Gestures - -// TODO: refactor to be split into other files -// TODO: refactor so that actions are separate from the gesture calculations, so that actions are more general - -extension VideoPlayer { - - private func handlePan( - action: PanAction, - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - guard !isGestureLocked else { return } - - switch action { - case .none: - return - case .audioffset: - audioOffsetAction(state: state, point: point, velocity: velocity, translation: translation) - case .brightness: - brightnessAction(state: state, point: point, velocity: velocity, translation: translation) - case .playbackSpeed: - playbackSpeedAction(state: state, point: point, velocity: velocity, translation: translation) - case .scrub: - scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 1) - case .slowScrub: - scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 0.1) - case .subtitleOffset: - subtitleOffsetAction(state: state, point: point, velocity: velocity, translation: translation) - case .volume: - volumeAction(state: state, point: point, velocity: velocity, translation: translation) - } - } - - private func handleHorizontalSwipe( - unitPoint: UnitPoint, - direction: Bool, - amount: Int - ) { - guard !isGestureLocked else { return } - - switch horizontalSwipeGesture { - case .none: - return - case .jump: - jumpAction(unitPoint: .init(x: direction ? 1 : 0, y: 0), amount: amount) - } - } - - private func handleLongPress(point: UnitPoint) { - switch longPressGesture { - case .none: - return - case .gestureLock: - guard !isPresentingOverlay else { return } - isGestureLocked.toggle() - } - } - - private func handlePinchGesture(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) { - guard !isGestureLocked else { return } - - switch pinchGesture { - case .none: - return - case .aspectFill: - aspectFillAction(state: state, unitPoint: unitPoint, scale: scale) - } - } - - private func handleTapGesture(unitPoint: UnitPoint, taps: Int) { - guard !isGestureLocked else { - updateViewProxy.present(systemName: "lock.fill", title: L10n.gesturesLocked) - return - } - - if taps > 1 && multiTapGesture != .none { - - withAnimation(.linear(duration: 0.1)) { - isPresentingOverlay = false - } - - switch multiTapGesture { - case .none: - return - case .jump: - jumpAction(unitPoint: unitPoint, amount: taps - 1) - } - } else { - withAnimation(.linear(duration: 0.1)) { - isPresentingOverlay = !isPresentingOverlay - } - } - } - - private func handleDoubleTouchGesture(unitPoint: UnitPoint, taps: Int) { - if doubleTouchGesture == .gestureLock { - guard !isPresentingOverlay else { return } - isGestureLocked.toggle() - } - - guard !isGestureLocked else { - updateViewProxy.present(systemName: "lock.fill", title: L10n.gesturesLocked) - return - } - - switch doubleTouchGesture { - case .none: - return - case .aspectFill: () - case .pausePlay: - switch videoPlayerManager.state { - case .playing: - videoPlayerManager.proxy.pause() - default: - videoPlayerManager.proxy.play() - } - default: - break - } - } -} - -// MARK: Actions - -extension VideoPlayer { - - private func aspectFillAction(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) { - guard state == .began || state == .changed else { return } - if scale > 1, !isAspectFilled { - isAspectFilled = true - } else if scale < 1, isAspectFilled { - isAspectFilled = false - } - } - - private func audioOffsetAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningAudioOffset = audioOffset - } else if state == .ended { - return - } - - let newOffset = gestureStateHandler.beginningAudioOffset - round( - Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000), - toNearest: 100 - ) - - updateViewProxy.present(systemName: "speaker.wave.2.fill", title: newOffset.millisecondLabel) - audioOffset = clamp(newOffset, min: -30000, max: 30000) - } - - private func brightnessAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningBrightnessValue = UIScreen.main.brightness - } else if state == .ended { - return - } - - let newBrightness = gestureStateHandler.beginningBrightnessValue - (gestureStateHandler.beginningHorizontalPanUnit - point) - let clampedBrightness = clamp(newBrightness, min: 0, max: 1.0) - let flashPercentage = Int(clampedBrightness * 100) - - if flashPercentage >= 67 { - updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%", iconSize: .init(width: 30, height: 30)) - } else if flashPercentage >= 33 { - updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%") - } else { - updateViewProxy.present(systemName: "sun.min.fill", title: "\(flashPercentage)%", iconSize: .init(width: 20, height: 20)) - } - - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) { - UIScreen.main.brightness = clampedBrightness - } - } - - // TODO: decide on overlay behavior? - private func jumpAction( - unitPoint: UnitPoint, - amount: Int - ) { - if unitPoint.x <= 0.5 { - videoPlayerManager.proxy.jumpBackward(Int(jumpBackwardLength.rawValue)) - - updateViewProxy.present(systemName: "gobackward", title: "\(amount * Int(jumpBackwardLength.rawValue))s") - } else { - videoPlayerManager.proxy.jumpForward(Int(jumpForwardLength.rawValue)) - - updateViewProxy.present(systemName: "goforward", title: "\(amount * Int(jumpForwardLength.rawValue))s") - } - } - - private func playbackSpeedAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningPlaybackSpeed = playbackSpeed - } else if state == .ended { - return - } - - let newPlaybackSpeed = round( - gestureStateHandler.beginningPlaybackSpeed - Double(gestureStateHandler.beginningHorizontalPanUnit - point) * 2, - toNearest: 0.25 - ) - let clampedPlaybackSpeed = clamp(newPlaybackSpeed, min: 0.25, max: 5.0) - - updateViewProxy.present(systemName: "speedometer", title: clampedPlaybackSpeed.rateLabel) - - playbackSpeed = clampedPlaybackSpeed - videoPlayerManager.proxy.setRate(.absolute(Float(clampedPlaybackSpeed))) - } - - private func scrubAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat, - rate: CGFloat - ) { - if state == .began { - isScrubbing = true - - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beganPanWithOverlay = isPresentingOverlay - } else if state == .ended { - if !gestureStateHandler.beganPanWithOverlay { - isPresentingOverlay = false - } - - isScrubbing = false - - return - } - - let newProgress = gestureStateHandler.beginningPanProgress - (gestureStateHandler.beginningHorizontalPanUnit - point) * rate - currentProgressHandler.scrubbedProgress = clamp(newProgress, min: 0, max: 1) - } - - private func subtitleOffsetAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningSubtitleOffset = subtitleOffset - } else if state == .ended { - return - } - - let newOffset = gestureStateHandler.beginningSubtitleOffset - round( - Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000), - toNearest: 100 - ) - let clampedOffset = clamp(newOffset, min: -30000, max: 30000) - - updateViewProxy.present(systemName: "captions.bubble.fill", title: clampedOffset.millisecondLabel) - - subtitleOffset = clampedOffset - } - - private func volumeAction( - state: UIGestureRecognizer.State, - point: CGFloat, - velocity: CGFloat, - translation: CGFloat - ) { - let volumeView = MPVolumeView() - guard let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider else { return } - - if state == .began { - gestureStateHandler.beginningPanProgress = currentProgressHandler.progress - gestureStateHandler.beginningHorizontalPanUnit = point - gestureStateHandler.beginningVolumeValue = AVAudioSession.sharedInstance().outputVolume - } else if state == .ended { - return - } - - let newVolume = gestureStateHandler.beginningVolumeValue - Float(gestureStateHandler.beginningHorizontalPanUnit - point) - - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) { - slider.value = newVolume - } - } -} diff --git a/jellypig.xcodeproj/xcshareddata/xcschemes/jellypig iOS.xcscheme b/jellypig.xcodeproj/xcshareddata/xcschemes/jellypig iOS.xcscheme deleted file mode 100644 index 4c303287..00000000 --- a/jellypig.xcodeproj/xcshareddata/xcschemes/jellypig iOS.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -