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 |