Remove iOS code and target - tvOS only fork
- Removed entire jellypig iOS directory - Removed jellypig iOS.xcscheme - jellypig is now tvOS-only for Apple TV usage - Focusing on Jellyfin.Xtream plugin compatibility
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Value: CustomStringConvertible & Strideable>: View {
|
|
||||||
|
|
||||||
@Binding
|
|
||||||
private var value: Value
|
|
||||||
|
|
||||||
private let title: String
|
|
||||||
private let range: ClosedRange<Value>
|
|
||||||
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<Value>,
|
|
||||||
range: ClosedRange<Value>,
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<String>()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Timer.TimerPublisher>
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Content: View>: View {
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
var content: () -> Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
SeparatorHStack(content)
|
|
||||||
.separator {
|
|
||||||
Circle()
|
|
||||||
.frame(width: 2, height: 2)
|
|
||||||
.padding(.horizontal, 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<ErrorType: Error>: 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<TimeInterval>
|
|
||||||
|
|
||||||
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<TimeInterval>!
|
|
||||||
|
|
||||||
func add(picker: UIDatePicker) {
|
|
||||||
picker.addTarget(
|
|
||||||
self,
|
|
||||||
action: #selector(
|
|
||||||
dateChanged
|
|
||||||
),
|
|
||||||
for: .valueChanged
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
func dateChanged(_ picker: UIDatePicker) {
|
|
||||||
interval.wrappedValue = picker.countDownDuration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Content: View>: 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<String>()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<TextPair> 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Leading: View, Content: View>: 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Content: View>: 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Element: Displayable & Hashable>: 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Item: Displayable>: 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Item: Poster>: 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Element: Poster & Identifiable, Data: Collection>: 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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<CGFloat>) {
|
|
||||||
self.init(
|
|
||||||
isEditing: .constant(false),
|
|
||||||
progress: progress,
|
|
||||||
trackMask: { Color.white },
|
|
||||||
topContent: { EmptyView() },
|
|
||||||
bottomContent: { EmptyView() },
|
|
||||||
leadingContent: { EmptyView() },
|
|
||||||
trailingContent: { EmptyView() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isEditing(_ isEditing: Binding<Bool>) -> 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<CGFloat>) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<CGFloat>) {
|
|
||||||
self.init(
|
|
||||||
isEditing: .constant(false),
|
|
||||||
progress: progress,
|
|
||||||
trackMask: { Color.white },
|
|
||||||
topContent: { EmptyView() },
|
|
||||||
bottomContent: { EmptyView() },
|
|
||||||
leadingContent: { EmptyView() },
|
|
||||||
trailingContent: { EmptyView() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isEditing(_ isEditing: Binding<Bool>) -> 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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<String>,
|
|
||||||
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<String> = .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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<iOS15Content: View, Content: View>: View {
|
|
||||||
|
|
||||||
let iOS15: () -> iOS15Content
|
|
||||||
let content: () -> Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if #available(iOS 16, *) {
|
|
||||||
content()
|
|
||||||
} else {
|
|
||||||
iOS15()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<AnyShapeStyle> {
|
|
||||||
|
|
||||||
static func sectionFooterWithImage<ImageStyle: ShapeStyle>(imageStyle: ImageStyle) -> SectionFooterWithImageLabelStyle<ImageStyle> {
|
|
||||||
SectionFooterWithImageLabelStyle(imageStyle: imageStyle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SectionFooterWithImageLabelStyle<ImageStyle: ShapeStyle>: LabelStyle {
|
|
||||||
|
|
||||||
let imageStyle: ImageStyle
|
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
HStack {
|
|
||||||
configuration.icon
|
|
||||||
.foregroundStyle(imageStyle)
|
|
||||||
.backport
|
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
configuration.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Drawer: View>: 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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Content: View, Drawer: View>: 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<Content, Drawer> {
|
|
||||||
UINavigationBarDrawerHostingController<Content, Drawer>(buttons: buttons, content: content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UINavigationBarDrawerHostingController<Content, Drawer>, context: Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
class UINavigationBarDrawerHostingController<Content: View, Drawer: View>: UIHostingController<Content> {
|
|
||||||
|
|
||||||
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<Drawer> = {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Content: View>: 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Content: View>: UIViewControllerRepresentable {
|
|
||||||
|
|
||||||
@Binding
|
|
||||||
private var scrollViewOffset: CGFloat
|
|
||||||
|
|
||||||
private let start: CGFloat
|
|
||||||
private let end: CGFloat
|
|
||||||
private let content: () -> Content
|
|
||||||
|
|
||||||
init(
|
|
||||||
scrollViewOffset: Binding<CGFloat>,
|
|
||||||
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<Content> {
|
|
||||||
UINavigationBarOffsetHostingController(rootView: content())
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UINavigationBarOffsetHostingController<Content>, context: Context) {
|
|
||||||
uiViewController.scrollViewDidScroll(scrollViewOffset, start: start, end: end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class UINavigationBarOffsetHostingController<Content: View>: UIHostingController<Content> {
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<Content: View>(@ViewBuilder _ content: (Self) -> Content) -> some View {
|
|
||||||
if #available(iOS 16, *) {
|
|
||||||
self
|
|
||||||
} else {
|
|
||||||
content(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectOrientation(_ orientation: Binding<UIDeviceOrientation>) -> some View {
|
|
||||||
modifier(DetectOrientation(orientation: orientation))
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigationBarOffset(_ scrollViewOffset: Binding<CGFloat>, start: CGFloat, end: CGFloat) -> some View {
|
|
||||||
modifier(NavigationBarOffsetModifier(scrollViewOffset: scrollViewOffset, start: start, end: end))
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigationBarDrawer<Drawer: View>(@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<Content: View>(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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<AnyCancellable>()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-dark-blue</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2532352%" y1="40.8257212%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#1F4EA7" offset="0%"></stop>
|
|
||||||
<stop stop-color="#00DDFF" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-dark-blue" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#000000" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-dark-blue.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-dark-green</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2532352%" y1="40.8257212%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#316D0C" offset="0%"></stop>
|
|
||||||
<stop stop-color="#7CD841" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-dark-green" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#000000" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-dark-green.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-dark-jellyfin</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.8247286%" y1="41.1617938%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#AA5CC3" offset="0%"></stop>
|
|
||||||
<stop stop-color="#00A4DC" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-dark-jellyfin" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#000000" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-dark-jellyfin.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-dark-orange</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2532352%" y1="40.8257212%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#D64800" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FFB657" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-dark-orange" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#000000" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-dark-orange.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-dark-red</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2532352%" y1="40.8257212%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#B42222" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FF8383" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-dark-red" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#000000" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-dark-red.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-dark-yellow</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.8247286%" y1="41.1617938%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#988E0D" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FFEE00" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-dark-yellow" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#000000" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-dark-yellow.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedDark-blue</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="18.6834859%" y1="40.8257212%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#1F4EA7" offset="0%"></stop>
|
|
||||||
<stop stop-color="#00DDFF" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedDark-blue" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#000000"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedDark-blue.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedDark-green</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="18.6834859%" y1="40.8257212%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#316D0C" offset="0%"></stop>
|
|
||||||
<stop stop-color="#7CD841" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedDark-green" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#000000"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedDark-green.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedDark-jellyfin</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2655693%" y1="41.1617938%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#AA5CC3" offset="0%"></stop>
|
|
||||||
<stop stop-color="#00A4DC" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedDark-jellyfin" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#000000"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedDark-jellyfin.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedDark-orange</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="18.6834859%" y1="40.8257212%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#D64800" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FFB657" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedDark-orange" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#000000"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedDark-orange.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedDark-red</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="18.6834859%" y1="40.8257212%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#B42222" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FF8383" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedDark-red" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#000000"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedDark-red.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedDark-yellow</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2655693%" y1="41.1617938%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#988E0D" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FFEE00" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Dark" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedDark-yellow" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#000000"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedDark-yellow.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedLight-blue</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="18.6834859%" y1="40.8257212%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#1F4EA7" offset="0%"></stop>
|
|
||||||
<stop stop-color="#00DDFF" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedLight-blue" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#FFFFFF"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedLight-blue.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedLight-green</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="18.6834859%" y1="40.8257212%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#316D0C" offset="0%"></stop>
|
|
||||||
<stop stop-color="#7CD841" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedLight-green" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#FFFFFF"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedLight-green.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedLight-jellyfin</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2655693%" y1="41.1617938%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#AA5CC3" offset="0%"></stop>
|
|
||||||
<stop stop-color="#00A4DC" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedLight-jellyfin" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#FFFFFF"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedLight-jellyfin.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedLight-orange</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="18.6834859%" y1="40.8257212%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#D64800" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FFB657" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedLight-orange" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#FFFFFF"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedLight-orange.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedLight-red</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="18.6834859%" y1="40.8257212%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#B42222" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FF8383" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedLight-red" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#FFFFFF"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedLight-red.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-invertedLight-yellow</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2655693%" y1="41.1617938%" x2="101.597525%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#988E0D" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FFEE00" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Inverted-Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-invertedLight-yellow" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="url(#linearGradient-1)" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="#FFFFFF"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-invertedLight-yellow.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-light-blue</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2532352%" y1="40.8257212%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#1F4EA7" offset="0%"></stop>
|
|
||||||
<stop stop-color="#00DDFF" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-light-blue" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#FFFFFF" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-light-blue.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-light-green</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2532352%" y1="40.8257212%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#316D0C" offset="0%"></stop>
|
|
||||||
<stop stop-color="#7CD841" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-light-green" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#FFFFFF" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-light-green.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-light-jellyfin</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.8247286%" y1="41.1617938%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#AA5CC3" offset="0%"></stop>
|
|
||||||
<stop stop-color="#00A4DC" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-light-jellyfin" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#FFFFFF" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-light-jellyfin.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-light-orange</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2532352%" y1="40.8257212%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#D64800" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FFB657" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-light-orange" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#FFFFFF" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-light-orange.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-light-red</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2532352%" y1="40.8257212%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#B42222" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FF8383" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-light-red" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#FFFFFF" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-light-red.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>AppIcon-light-yellow</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.8247286%" y1="41.1617938%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#988E0D" offset="0%"></stop>
|
|
||||||
<stop stop-color="#FFEE00" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Light" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="AppIcon-light-yellow" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#FFFFFF" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-light-yellow.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024px" height="1024px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<title>primary</title>
|
|
||||||
<defs>
|
|
||||||
<linearGradient x1="19.2532352%" y1="40.8257212%" x2="100.658798%" y2="88.6971024%" id="linearGradient-1">
|
|
||||||
<stop stop-color="#1F4EA7" offset="0%"></stop>
|
|
||||||
<stop stop-color="#00DDFF" offset="100%"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g id="Primary" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
|
||||||
<g id="primary" fill-rule="nonzero">
|
|
||||||
<rect id="solid-background" fill="#020B23" x="0" y="0" width="1024" height="1024"></rect>
|
|
||||||
<path d="M511.582082,136 C609.711866,136 924.91518,714.816848 877.299462,811.251498 C829.683744,907.686149 194.101496,908.81122 145.960253,811.251498 C97.8190104,713.691777 413.547848,136 511.582082,136 Z M511.677632,288.155769 C447.420301,288.155769 240.55469,666.677846 272.102093,730.597947 C303.649497,794.518049 720.072042,793.810861 751.269096,730.597947 C782.46615,667.368961 575.934964,288.155769 511.677632,288.155769 Z M511.109544,425.091384 C543.724099,425.107443 648.323295,617.009305 632.517902,648.998301 C616.71251,680.987298 405.681839,681.34059 389.701185,648.998301 C373.720531,616.656013 478.542787,425.091384 511.109544,425.091384 Z" id="Combined-Shape" fill="url(#linearGradient-1)"></path>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "AppIcon-primary-primary.svg",
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
},
|
|
||||||
"properties" : {
|
|
||||||
"preserves-vector-representation" : true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
Before Width: | Height: | Size: 302 KiB |