[iOS] Media Item Menu - Edit Item Images (#1345)

* Good start but some missing items:

- Upload image isn't working
- Only a single image is shown per section. Need to make this the HCollection of all images for the group

* Upload still failing but now update and set are 2 different processes because I think that's better. Spacing on the add screen is still all wrong but we're getting closer

* ~70% Complete

TODO:

- Spacing for remote portrait images is wrong & cramped
- Upload image from file browser never works & produces 400 error
- Show all images for an item.imageType opposed to just the first
- Setting image works but produces a 400 error
- Error alert looks bad

* Merge with Main

* URL Changes

* Updating logic and confirmation screen

* Lots of changes:

Selecting a Remote image is now working without error and works consistently!

Upload a local file is still broken

Item types with multiple images is working as intended now!

Overriding an image on index doesn't seem to work but it doesn't work for Web either so........

UI is way more jank but the hard parts are getting solved!

* Breaking this even more with the hopes of a better tomorrow.

* Getting better?

* Refreshing is working but I might need to make this work mroe effiently...

* 90% There!

* Ability to cancel the update

* Still no luck uploading images?

* Stop reordering on deletion/addition

* 2025 disclaimers

* Uploading finally works!

* Functional but messy.

TODO:
- Figure out better resizing if too big?
- Upload from Photos
- Move upload logic to imageViewModel and make RemtoeImageViewModel PagingLibraryViewModel conformant
- Create a ImageInfoView for Selection & Deletion.

* Now conforms to PagingLIbraryViewModel but everything else is a mess

* Close!

* First no all appears

* Fix double pop/routerdismiss

* Uploading from Photos is (Finally) Ready!

* wip

* Reuse PhotoPicker and Crop code.

* 4/6 of the codefactor changes

* Pass around the URL NOT the UIImage

* Clean up ItemImageDetails types.

* Make sure the ImageView mirrors the real shape of the image. Posters should be uniform but this is the selection for the image so the dimensions are important to demonstrate.

* Rating Type label.

* Delete confirmation dialog.

* Remove double sizing. Remove Unused ViewModel. Change PhotoPicker to a checkmark instead a 1. Since there is only ever one picture selected, no need to count the images.

* Get the image URL as needed. No more Truples.  Localize ImageTypes.

* Remove attempt at ImageInfo Poster Comformance.

* Even more cleanup

* Delete vs Save flip

* Hide delete button

* Even more cleanup

* Fix tvOS build issues.

* Reduce delay & remove unused comment. Should finally be ready again.

* wip

* Update ItemImagesView.swift

* Event Only on upload failures.

* Remove unnecessary ViewModel's from tvOS.

* Add dismiss action to RemoteSearchResultView. While I am doing this here, fix it there.

* Move From Coordinator -> .Sheet. This fixes the popping issue / delay requirement!

* wip

* wip

* wip

* wip

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2025-01-20 14:17:35 -07:00 committed by GitHub
parent 1530668042
commit 553441d83e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2923 additions and 365 deletions

View File

@ -26,6 +26,11 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {
@Route(.modal) @Route(.modal)
var editMetadata = makeEditMetadata var editMetadata = makeEditMetadata
// MARK: - Route to Images
@Route(.modal)
var editImages = makeEditImages
// MARK: - Route to Genres // MARK: - Route to Genres
@Route(.push) @Route(.push)
@ -73,6 +78,12 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {
} }
} }
// MARK: - Item Images
func makeEditImages(viewModel: ItemImagesViewModel) -> NavigationViewCoordinator<ItemImagesCoordinator> {
NavigationViewCoordinator(ItemImagesCoordinator(viewModel: viewModel))
}
// MARK: - Item Genres // MARK: - Item Genres
@ViewBuilder @ViewBuilder

View File

@ -0,0 +1,52 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import Stinsen
import SwiftUI
final class ItemImagePickerCoordinator: NavigationCoordinatable {
// MARK: - Navigation Stack
let stack = Stinsen.NavigationStack(initial: \ItemImagePickerCoordinator.start)
@Root
var start = makeStart
// MARK: - Routes
@Route(.push)
var cropImage = makeCropImage
// MARK: - Observed Object
private let viewModel: ItemImagesViewModel
// MARK: - Image Variable
let type: ImageType
// MARK: - Initializer
init(viewModel: ItemImagesViewModel, type: ImageType) {
self.viewModel = viewModel
self.type = type
}
// MARK: - Crop Image View
func makeCropImage(image: UIImage) -> some View {
ItemPhotoCropView(viewModel: viewModel, image: image, type: type)
}
@ViewBuilder
func makeStart() -> some View {
ItemImagePicker()
}
}

View File

@ -0,0 +1,60 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Factory
import JellyfinAPI
import Stinsen
import SwiftUI
final class ItemImagesCoordinator: ObservableObject, NavigationCoordinatable {
// MARK: - Navigation Stack
let stack = NavigationStack(initial: \ItemImagesCoordinator.start)
@Root
var start = makeStart
private let viewModel: ItemImagesViewModel
// MARK: - Route to Add Remote Image
@Route(.push)
var addImage = makeAddImage
// MARK: - Route to Photo Picker
@Route(.modal)
var photoPicker = makePhotoPicker
// MARK: - Initializer
init(viewModel: ItemImagesViewModel) {
self.viewModel = viewModel
}
// MARK: - Add Remote Images View
@ViewBuilder
func makeAddImage(imageType: ImageType) -> some View {
AddItemImageView(viewModel: viewModel, imageType: imageType)
}
// MARK: - Photo Picker View
func makePhotoPicker(type: ImageType) -> NavigationViewCoordinator<ItemImagePickerCoordinator> {
NavigationViewCoordinator(ItemImagePickerCoordinator(viewModel: self.viewModel, type: type))
}
// MARK: - Start
@ViewBuilder
func makeStart() -> some View {
ItemImagesView(viewModel: self.viewModel)
}
}

View File

@ -11,7 +11,7 @@ import SwiftUI
final class UserProfileImageCoordinator: NavigationCoordinatable { final class UserProfileImageCoordinator: NavigationCoordinatable {
// MARK: - Navigation Components // MARK: - Navigation Stack
let stack = Stinsen.NavigationStack(initial: \UserProfileImageCoordinator.start) let stack = Stinsen.NavigationStack(initial: \UserProfileImageCoordinator.start)
@ -37,19 +37,11 @@ final class UserProfileImageCoordinator: NavigationCoordinatable {
// MARK: - Views // MARK: - Views
func makeCropImage(image: UIImage) -> some View { func makeCropImage(image: UIImage) -> some View {
#if os(iOS) UserProfileImageCropView(viewModel: viewModel, image: image)
UserProfileImagePicker.SquareImageCropView(viewModel: viewModel, image: image)
#else
AssertionFailureView("not implemented")
#endif
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
#if os(iOS) UserProfileImagePickerView()
UserProfileImagePicker(viewModel: viewModel)
#else
AssertionFailureView("not implemented")
#endif
} }
} }

View File

@ -0,0 +1,36 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL 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
extension ImageInfo: @retroactive Identifiable {
public var id: Int {
hashValue
}
}
extension ImageInfo {
func itemImageSource(itemID: String, client: JellyfinClient) -> ImageSource {
let parameters = Paths.GetItemImageParameters(
tag: imageTag,
imageIndex: imageIndex
)
let request = Paths.getItemImage(
itemID: itemID,
imageType: imageType?.rawValue ?? "",
parameters: parameters
)
let itemImageURL = client.fullURL(with: request)
return ImageSource(url: itemImageURL)
}
}

View File

@ -0,0 +1,44 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL 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
extension ImageType: Displayable {
var displayTitle: String {
switch self {
case .primary:
return L10n.primary
case .art:
return L10n.art
case .backdrop:
return L10n.backdrop
case .banner:
return L10n.banner
case .logo:
return L10n.logo
case .thumb:
return L10n.thumb
case .disc:
return L10n.disc
case .box:
return L10n.box
case .screenshot:
return L10n.screenshot
case .menu:
return L10n.menu
case .chapter:
return L10n.chapter
case .boxRear:
return L10n.boxRear
case .profile:
return L10n.profile
}
}
}

View File

@ -0,0 +1,34 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
import SwiftUI
extension RemoteImageInfo: @retroactive Identifiable, Poster {
var displayTitle: String {
providerName ?? L10n.unknown
}
var unwrappedIDHashOrZero: Int {
id
}
var subtitle: String? {
language
}
var systemImage: String {
"photo"
}
public var id: Int {
hashValue
}
}

View File

@ -55,6 +55,9 @@ extension ImagePipeline.Swiftfin {
static let local: ImagePipeline = ImagePipeline(delegate: SwiftfinImagePipelineDelegate()) { static let local: ImagePipeline = ImagePipeline(delegate: SwiftfinImagePipelineDelegate()) {
$0.dataCache = DataCache.Swiftfin.local $0.dataCache = DataCache.Swiftfin.local
} }
/// An `ImagePipeline` for images to prevent more important images from losing their cache.
static let other: ImagePipeline = ImagePipeline(configuration: .withURLCache)
} }
final class SwiftfinImagePipelineDelegate: ImagePipelineDelegate { final class SwiftfinImagePipelineDelegate: ImagePipelineDelegate {

View File

@ -6,11 +6,16 @@
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors // Copyright (c) 2025 Jellyfin & Jellyfin Contributors
// //
import Foundation import JellyfinAPI
extension Hashable { extension RatingType: Displayable {
var hashString: String { var displayTitle: String {
"\(hashValue)" switch self {
case .score:
return L10n.score
case .likes:
return L10n.likes
}
} }
} }

View File

@ -78,10 +78,14 @@ internal enum L10n {
} }
/// Album Artist /// Album Artist
internal static let albumArtist = L10n.tr("Localizable", "albumArtist", fallback: "Album Artist") internal static let albumArtist = L10n.tr("Localizable", "albumArtist", fallback: "Album Artist")
/// All
internal static let all = L10n.tr("Localizable", "all", fallback: "All")
/// All Audiences /// All Audiences
internal static let allAudiences = L10n.tr("Localizable", "allAudiences", fallback: "All Audiences") internal static let allAudiences = L10n.tr("Localizable", "allAudiences", fallback: "All Audiences")
/// View all past and present devices that have connected. /// View all past and present devices that have connected.
internal static let allDevicesDescription = L10n.tr("Localizable", "allDevicesDescription", fallback: "View all past and present devices that have connected.") internal static let allDevicesDescription = L10n.tr("Localizable", "allDevicesDescription", fallback: "View all past and present devices that have connected.")
/// All languages
internal static let allLanguages = L10n.tr("Localizable", "allLanguages", fallback: "All languages")
/// All Media /// All Media
internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media") internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media")
/// Allow collection management /// Allow collection management
@ -120,6 +124,8 @@ internal enum L10n {
internal static let applicationName = L10n.tr("Localizable", "applicationName", fallback: "Application Name") internal static let applicationName = L10n.tr("Localizable", "applicationName", fallback: "Application Name")
/// Arranger /// Arranger
internal static let arranger = L10n.tr("Localizable", "arranger", fallback: "Arranger") internal static let arranger = L10n.tr("Localizable", "arranger", fallback: "Arranger")
/// Art
internal static let art = L10n.tr("Localizable", "art", fallback: "Art")
/// Artist /// Artist
internal static let artist = L10n.tr("Localizable", "artist", fallback: "Artist") internal static let artist = L10n.tr("Localizable", "artist", fallback: "Artist")
/// Aspect Fill /// Aspect Fill
@ -156,6 +162,10 @@ internal enum L10n {
internal static let autoPlay = L10n.tr("Localizable", "autoPlay", fallback: "Auto Play") internal static let autoPlay = L10n.tr("Localizable", "autoPlay", fallback: "Auto Play")
/// Back /// Back
internal static let back = L10n.tr("Localizable", "back", fallback: "Back") internal static let back = L10n.tr("Localizable", "back", fallback: "Back")
/// Backdrop
internal static let backdrop = L10n.tr("Localizable", "backdrop", fallback: "Backdrop")
/// Banner
internal static let banner = L10n.tr("Localizable", "banner", fallback: "Banner")
/// Bar Buttons /// Bar Buttons
internal static let barButtons = L10n.tr("Localizable", "barButtons", fallback: "Bar Buttons") internal static let barButtons = L10n.tr("Localizable", "barButtons", fallback: "Bar Buttons")
/// Behavior /// Behavior
@ -222,6 +232,10 @@ internal enum L10n {
internal static let blue = L10n.tr("Localizable", "blue", fallback: "Blue") internal static let blue = L10n.tr("Localizable", "blue", fallback: "Blue")
/// Books /// Books
internal static let books = L10n.tr("Localizable", "books", fallback: "Books") internal static let books = L10n.tr("Localizable", "books", fallback: "Books")
/// Box
internal static let box = L10n.tr("Localizable", "box", fallback: "Box")
/// BoxRear
internal static let boxRear = L10n.tr("Localizable", "boxRear", fallback: "BoxRear")
/// Bugs and Features /// Bugs and Features
internal static let bugsAndFeatures = L10n.tr("Localizable", "bugsAndFeatures", fallback: "Bugs and Features") internal static let bugsAndFeatures = L10n.tr("Localizable", "bugsAndFeatures", fallback: "Bugs and Features")
/// Buttons /// Buttons
@ -242,6 +256,8 @@ internal enum L10n {
internal static let changePin = L10n.tr("Localizable", "changePin", fallback: "Change Pin") internal static let changePin = L10n.tr("Localizable", "changePin", fallback: "Change Pin")
/// Channels /// Channels
internal static let channels = L10n.tr("Localizable", "channels", fallback: "Channels") internal static let channels = L10n.tr("Localizable", "channels", fallback: "Channels")
/// Chapter
internal static let chapter = L10n.tr("Localizable", "chapter", fallback: "Chapter")
/// Chapters /// Chapters
internal static let chapters = L10n.tr("Localizable", "chapters", fallback: "Chapters") internal static let chapters = L10n.tr("Localizable", "chapters", fallback: "Chapters")
/// Chapter Slider /// Chapter Slider
@ -398,6 +414,8 @@ internal enum L10n {
} }
/// Are you sure you wish to delete this device? This session will be logged out. /// Are you sure you wish to delete this device? This session will be logged out.
internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out.") internal static let deleteDeviceWarning = L10n.tr("Localizable", "deleteDeviceWarning", fallback: "Are you sure you wish to delete this device? This session will be logged out.")
/// Delete image
internal static let deleteImage = L10n.tr("Localizable", "deleteImage", fallback: "Delete image")
/// Are you sure you want to delete this item? /// Are you sure you want to delete this item?
internal static let deleteItemConfirmation = L10n.tr("Localizable", "deleteItemConfirmation", fallback: "Are you sure you want to delete this item?") internal static let deleteItemConfirmation = L10n.tr("Localizable", "deleteItemConfirmation", fallback: "Are you sure you want to delete this item?")
/// Are you sure you want to delete this item? This action cannot be undone. /// Are you sure you want to delete this item? This action cannot be undone.
@ -464,6 +482,8 @@ internal enum L10n {
internal static let devices = L10n.tr("Localizable", "devices", fallback: "Devices") internal static let devices = L10n.tr("Localizable", "devices", fallback: "Devices")
/// Digital /// Digital
internal static let digital = L10n.tr("Localizable", "digital", fallback: "Digital") internal static let digital = L10n.tr("Localizable", "digital", fallback: "Digital")
/// Dimensions
internal static let dimensions = L10n.tr("Localizable", "dimensions", fallback: "Dimensions")
/// Direct Play /// Direct Play
internal static let direct = L10n.tr("Localizable", "direct", fallback: "Direct Play") internal static let direct = L10n.tr("Localizable", "direct", fallback: "Direct Play")
/// Plays content in its original format. May cause playback issues on unsupported media types. /// Plays content in its original format. May cause playback issues on unsupported media types.
@ -478,6 +498,8 @@ internal enum L10n {
internal static let directStream = L10n.tr("Localizable", "directStream", fallback: "Direct Stream") internal static let directStream = L10n.tr("Localizable", "directStream", fallback: "Direct Stream")
/// Disabled /// Disabled
internal static let disabled = L10n.tr("Localizable", "disabled", fallback: "Disabled") internal static let disabled = L10n.tr("Localizable", "disabled", fallback: "Disabled")
/// Disc
internal static let disc = L10n.tr("Localizable", "disc", fallback: "Disc")
/// Disclaimer /// Disclaimer
internal static let disclaimer = L10n.tr("Localizable", "disclaimer", fallback: "Disclaimer") internal static let disclaimer = L10n.tr("Localizable", "disclaimer", fallback: "Disclaimer")
/// Dismiss /// Dismiss
@ -624,6 +646,14 @@ internal enum L10n {
internal static let idle = L10n.tr("Localizable", "idle", fallback: "Idle") internal static let idle = L10n.tr("Localizable", "idle", fallback: "Idle")
/// Illustrator /// Illustrator
internal static let illustrator = L10n.tr("Localizable", "illustrator", fallback: "Illustrator") internal static let illustrator = L10n.tr("Localizable", "illustrator", fallback: "Illustrator")
/// Images
internal static let image = L10n.tr("Localizable", "image", fallback: "Images")
/// Images
internal static let images = L10n.tr("Localizable", "images", fallback: "Images")
/// Image source
internal static let imageSource = L10n.tr("Localizable", "imageSource", fallback: "Image source")
/// Index
internal static let index = L10n.tr("Localizable", "index", fallback: "Index")
/// Indicators /// Indicators
internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators") internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators")
/// Inker /// Inker
@ -696,6 +726,8 @@ internal enum L10n {
internal static let light = L10n.tr("Localizable", "light", fallback: "Light") internal static let light = L10n.tr("Localizable", "light", fallback: "Light")
/// Liked Items /// Liked Items
internal static let likedItems = L10n.tr("Localizable", "likedItems", fallback: "Liked Items") internal static let likedItems = L10n.tr("Localizable", "likedItems", fallback: "Liked Items")
/// Likes
internal static let likes = L10n.tr("Localizable", "likes", fallback: "Likes")
/// List /// List
internal static let list = L10n.tr("Localizable", "list", fallback: "List") internal static let list = L10n.tr("Localizable", "list", fallback: "List")
/// Live TV /// Live TV
@ -718,6 +750,8 @@ internal enum L10n {
internal static let lockedFields = L10n.tr("Localizable", "lockedFields", fallback: "Locked Fields") internal static let lockedFields = L10n.tr("Localizable", "lockedFields", fallback: "Locked Fields")
/// Locked users /// Locked users
internal static let lockedUsers = L10n.tr("Localizable", "lockedUsers", fallback: "Locked users") internal static let lockedUsers = L10n.tr("Localizable", "lockedUsers", fallback: "Locked users")
/// Logo
internal static let logo = L10n.tr("Localizable", "logo", fallback: "Logo")
/// Logs /// Logs
internal static let logs = L10n.tr("Localizable", "logs", fallback: "Logs") internal static let logs = L10n.tr("Localizable", "logs", fallback: "Logs")
/// Access the Jellyfin server logs for troubleshooting and monitoring purposes. /// Access the Jellyfin server logs for troubleshooting and monitoring purposes.
@ -758,6 +792,8 @@ internal enum L10n {
internal static let mediaPlayback = L10n.tr("Localizable", "mediaPlayback", fallback: "Media playback") internal static let mediaPlayback = L10n.tr("Localizable", "mediaPlayback", fallback: "Media playback")
/// Mbps /// Mbps
internal static let megabitsPerSecond = L10n.tr("Localizable", "megabitsPerSecond", fallback: "Mbps") internal static let megabitsPerSecond = L10n.tr("Localizable", "megabitsPerSecond", fallback: "Mbps")
/// Menu
internal static let menu = L10n.tr("Localizable", "menu", fallback: "Menu")
/// Menu Buttons /// Menu Buttons
internal static let menuButtons = L10n.tr("Localizable", "menuButtons", fallback: "Menu Buttons") internal static let menuButtons = L10n.tr("Localizable", "menuButtons", fallback: "Menu Buttons")
/// Metadata /// Metadata
@ -926,6 +962,8 @@ internal enum L10n {
internal static let productionLocations = L10n.tr("Localizable", "productionLocations", fallback: "Production Locations") internal static let productionLocations = L10n.tr("Localizable", "productionLocations", fallback: "Production Locations")
/// Production Year /// Production Year
internal static let productionYear = L10n.tr("Localizable", "productionYear", fallback: "Production Year") internal static let productionYear = L10n.tr("Localizable", "productionYear", fallback: "Production Year")
/// Profile
internal static let profile = L10n.tr("Localizable", "profile", fallback: "Profile")
/// Profile Image /// Profile Image
internal static let profileImage = L10n.tr("Localizable", "profileImage", fallback: "Profile Image") internal static let profileImage = L10n.tr("Localizable", "profileImage", fallback: "Profile Image")
/// Profiles /// Profiles
@ -1066,6 +1104,10 @@ internal enum L10n {
internal static let saveUserWithoutAuthDescription = L10n.tr("Localizable", "saveUserWithoutAuthDescription", fallback: "Save the user to this device without any local authentication.") internal static let saveUserWithoutAuthDescription = L10n.tr("Localizable", "saveUserWithoutAuthDescription", fallback: "Save the user to this device without any local authentication.")
/// Schedule already exists /// Schedule already exists
internal static let scheduleAlreadyExists = L10n.tr("Localizable", "scheduleAlreadyExists", fallback: "Schedule already exists") internal static let scheduleAlreadyExists = L10n.tr("Localizable", "scheduleAlreadyExists", fallback: "Schedule already exists")
/// Score
internal static let score = L10n.tr("Localizable", "score", fallback: "Score")
/// Screenshot
internal static let screenshot = L10n.tr("Localizable", "screenshot", fallback: "Screenshot")
/// Scrub Current Time /// Scrub Current Time
internal static let scrubCurrentTime = L10n.tr("Localizable", "scrubCurrentTime", fallback: "Scrub Current Time") internal static let scrubCurrentTime = L10n.tr("Localizable", "scrubCurrentTime", fallback: "Scrub Current Time")
/// Search /// Search
@ -1250,6 +1292,8 @@ internal enum L10n {
internal static let terabitsPerSecond = L10n.tr("Localizable", "terabitsPerSecond", fallback: "Tbps") internal static let terabitsPerSecond = L10n.tr("Localizable", "terabitsPerSecond", fallback: "Tbps")
/// Test Size /// Test Size
internal static let testSize = L10n.tr("Localizable", "testSize", fallback: "Test Size") internal static let testSize = L10n.tr("Localizable", "testSize", fallback: "Test Size")
/// Thumb
internal static let thumb = L10n.tr("Localizable", "thumb", fallback: "Thumb")
/// Time /// Time
internal static let time = L10n.tr("Localizable", "time", fallback: "Time") internal static let time = L10n.tr("Localizable", "time", fallback: "Time")
/// Time Limit /// Time Limit
@ -1320,6 +1364,10 @@ internal enum L10n {
internal static let unreleased = L10n.tr("Localizable", "unreleased", fallback: "Unreleased") internal static let unreleased = L10n.tr("Localizable", "unreleased", fallback: "Unreleased")
/// You have unsaved changes. Are you sure you want to discard them? /// You have unsaved changes. Are you sure you want to discard them?
internal static let unsavedChangesMessage = L10n.tr("Localizable", "unsavedChangesMessage", fallback: "You have unsaved changes. Are you sure you want to discard them?") internal static let unsavedChangesMessage = L10n.tr("Localizable", "unsavedChangesMessage", fallback: "You have unsaved changes. Are you sure you want to discard them?")
/// Upload file
internal static let uploadFile = L10n.tr("Localizable", "uploadFile", fallback: "Upload file")
/// Upload photo
internal static let uploadPhoto = L10n.tr("Localizable", "uploadPhoto", fallback: "Upload photo")
/// URL /// URL
internal static let url = L10n.tr("Localizable", "url", fallback: "URL") internal static let url = L10n.tr("Localizable", "url", fallback: "URL")
/// Use as Transcoding Profile /// Use as Transcoding Profile
@ -1374,6 +1422,8 @@ internal enum L10n {
internal static let videoTranscoding = L10n.tr("Localizable", "videoTranscoding", fallback: "Video transcoding") internal static let videoTranscoding = L10n.tr("Localizable", "videoTranscoding", fallback: "Video transcoding")
/// Some views may need an app restart to update. /// Some views may need an app restart to update.
internal static let viewsMayRequireRestart = L10n.tr("Localizable", "viewsMayRequireRestart", fallback: "Some views may need an app restart to update.") internal static let viewsMayRequireRestart = L10n.tr("Localizable", "viewsMayRequireRestart", fallback: "Some views may need an app restart to update.")
/// Votes
internal static let votes = L10n.tr("Localizable", "votes", fallback: "Votes")
/// Weekday /// Weekday
internal static let weekday = L10n.tr("Localizable", "weekday", fallback: "Weekday") internal static let weekday = L10n.tr("Localizable", "weekday", fallback: "Weekday")
/// Weekend /// Weekend

View File

@ -0,0 +1,399 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL 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 OrderedCollections
import SwiftUI
class ItemImagesViewModel: ViewModel, Stateful, Eventful {
enum Event: Equatable {
case updated
case error(JellyfinAPIError)
}
enum Action: Equatable {
case cancel
case refresh
case setImage(RemoteImageInfo)
case uploadImage(image: UIImage, type: ImageType)
case uploadFile(file: URL, type: ImageType)
case deleteImage(ImageInfo)
}
enum BackgroundState: Hashable {
case updating
}
enum State: Hashable {
case initial
case content
case error(JellyfinAPIError)
}
// MARK: - Published Variables
@Published
var item: BaseItemDto
@Published
var images: [ImageType: [ImageInfo]] = [:]
// MARK: - State Management
@Published
var state: State = .initial
@Published
var backgroundStates: OrderedSet<BackgroundState> = []
private var task: AnyCancellable?
private let eventSubject = PassthroughSubject<Event, Never>()
// MARK: - Eventful
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
// MARK: - Init
init(item: BaseItemDto) {
self.item = item
}
// MARK: - Respond to Actions
func respond(to action: Action) -> State {
switch action {
case .cancel:
task?.cancel()
return .initial
case .refresh:
task?.cancel()
task = Task { [weak self] in
guard let self else { return }
do {
await MainActor.run {
_ = self.backgroundStates.append(.updating)
self.images.removeAll()
}
try await self.getAllImages()
await MainActor.run {
self.state = .content
_ = self.backgroundStates.remove(.updating)
}
} catch {
let apiError = JellyfinAPIError(error.localizedDescription)
await MainActor.run {
self.state = .error(apiError)
self.eventSubject.send(.error(apiError))
self.backgroundStates.remove(.updating)
}
}
}.asAnyCancellable()
return .initial
case let .setImage(remoteImageInfo):
task?.cancel()
task = Task { [weak self] in
guard let self = self else { return }
do {
await MainActor.run {
_ = self.backgroundStates.append(.updating)
}
try await self.setImage(remoteImageInfo)
try await self.getAllImages()
await MainActor.run {
self.eventSubject.send(.updated)
}
} catch {
let apiError = JellyfinAPIError(error.localizedDescription)
await MainActor.run {
self.eventSubject.send(.error(apiError))
}
}
await MainActor.run {
_ = self.backgroundStates.remove(.updating)
}
}.asAnyCancellable()
return .content
case let .uploadImage(image, type):
task?.cancel()
task = Task { [weak self] in
guard let self = self else { return }
do {
await MainActor.run {
_ = self.backgroundStates.append(.updating)
}
try await self.uploadPhoto(image, type: type)
try await self.getAllImages()
} catch {
let apiError = JellyfinAPIError(error.localizedDescription)
await MainActor.run {
self.eventSubject.send(.error(apiError))
}
}
await MainActor.run {
_ = self.backgroundStates.remove(.updating)
}
}.asAnyCancellable()
return .content
case let .uploadFile(url, type):
task?.cancel()
task = Task { [weak self] in
guard let self = self else { return }
do {
await MainActor.run {
_ = self.backgroundStates.append(.updating)
}
try await self.uploadFile(url, type: type)
try await self.getAllImages()
await MainActor.run {
self.eventSubject.send(.updated)
}
} catch {
let apiError = JellyfinAPIError(error.localizedDescription)
await MainActor.run {
self.eventSubject.send(.error(apiError))
}
}
await MainActor.run {
_ = self.backgroundStates.remove(.updating)
}
}.asAnyCancellable()
return .content
case let .deleteImage(imageInfo):
task?.cancel()
task = Task { [weak self] in
guard let self = self else { return }
do {
await MainActor.run {
_ = self.backgroundStates.append(.updating)
}
try await deleteImage(imageInfo)
try await refreshItem()
await MainActor.run {
self.eventSubject.send(.updated)
}
} catch {
let apiError = JellyfinAPIError(error.localizedDescription)
await MainActor.run {
self.eventSubject.send(.error(apiError))
}
}
await MainActor.run {
_ = self.backgroundStates.remove(.updating)
}
}.asAnyCancellable()
return .content
}
}
// MARK: - Get All Item Images
private func getAllImages() async throws {
guard let itemID = item.id else { return }
let request = Paths.getItemImageInfos(itemID: itemID)
let response = try await self.userSession.client.send(request)
let newImages: [ImageType: [ImageInfo]] = response.value.grouped(by: \.imageType)
.mapValues { $0.sorted(using: \.imageIndex) }
.reduce(into: [:]) { partialResult, kv in
guard let k = kv.key else { return }
partialResult[k] = kv.value
}
await MainActor.run {
self.images = newImages
}
}
// MARK: - Set Image From URL
private func setImage(_ remoteImageInfo: RemoteImageInfo) async throws {
guard let itemID = item.id,
let type = remoteImageInfo.type,
let imageURL = remoteImageInfo.url else { return }
let parameters = Paths.DownloadRemoteImageParameters(type: type, imageURL: imageURL)
let imageRequest = Paths.downloadRemoteImage(itemID: itemID, parameters: parameters)
try await userSession.client.send(imageRequest)
}
// MARK: - Upload Image/File
private func upload(imageData: Data, imageType: ImageType, contentType: String) async throws {
guard let itemID = item.id else { return }
let uploadLimit: Int = 30_000_000
guard imageData.count <= uploadLimit else {
throw JellyfinAPIError(
"This image (\(imageData.count.formatted(.byteCount(style: .file)))) exceeds the maximum allowed size for upload (\(uploadLimit.formatted(.byteCount(style: .file)))."
)
}
var request = Paths.setItemImage(
itemID: itemID,
imageType: imageType.rawValue,
imageData.base64EncodedData()
)
request.headers = ["Content-Type": contentType]
_ = try await userSession.client.send(request)
}
// MARK: - Prepare Photo for Upload
private func uploadPhoto(_ image: UIImage, type: ImageType) async throws {
let contentType: String
let imageData: Data
if let pngData = image.pngData() {
contentType = "image/png"
imageData = pngData
} else if let jpgData = image.jpegData(compressionQuality: 1) {
contentType = "image/jpeg"
imageData = jpgData
} else {
logger.error("Unable to convert given profile image to png/jpg")
throw JellyfinAPIError("An internal error occurred")
}
try await upload(
imageData: imageData,
imageType: type,
contentType: contentType
)
}
// MARK: - Prepare Image for Upload
private func uploadFile(_ url: URL, type: ImageType) async throws {
guard url.startAccessingSecurityScopedResource() else {
logger.error("Unable to access file at \(url)")
throw JellyfinAPIError("An internal error occurred.")
}
defer { url.stopAccessingSecurityScopedResource() }
let contentType: String
let imageData: Data
switch url.pathExtension.lowercased() {
case "png":
contentType = "image/png"
imageData = try Data(contentsOf: url)
case "jpeg", "jpg":
contentType = "image/jpeg"
imageData = try Data(contentsOf: url)
default:
guard let image = try UIImage(data: Data(contentsOf: url)) else {
logger.error("Unable to load image from file")
throw JellyfinAPIError("An internal error occurred.")
}
if let pngData = image.pngData() {
contentType = "image/png"
imageData = pngData
} else if let jpgData = image.jpegData(compressionQuality: 1) {
contentType = "image/jpeg"
imageData = jpgData
} else {
logger.error("Failed to convert image to png/jpg")
throw JellyfinAPIError("An internal error occurred.")
}
}
try await upload(
imageData: imageData,
imageType: type,
contentType: contentType
)
}
// MARK: - Delete Image
private func deleteImage(_ imageInfo: ImageInfo) async throws {
guard let itemID = item.id,
let imageType = imageInfo.imageType else { return }
if let imageIndex = imageInfo.imageIndex {
let request = Paths.deleteItemImageByIndex(
itemID: itemID,
imageType: imageType.rawValue,
imageIndex: imageIndex
)
try await userSession.client.send(request)
} else {
let request = Paths.deleteItemImage(
itemID: itemID,
imageType: imageType.rawValue
)
try await userSession.client.send(request)
}
try await getAllImages()
}
// MARK: - Refresh Item
private func refreshItem() async throws {
guard let itemID = item.id else { return }
await MainActor.run {
_ = backgroundStates.append(.updating)
}
let request = Paths.getItem(
userID: userSession.user.id,
itemID: itemID
)
let response = try await userSession.client.send(request)
await MainActor.run {
self.item = response.value
_ = backgroundStates.remove(.updating)
Notifications[.itemMetadataDidChange].post(item)
}
}
}

View File

@ -0,0 +1,64 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
class RemoteImageInfoViewModel: PagingLibraryViewModel<RemoteImageInfo> {
// Image providers come from the paging call
@Published
private(set) var providers: [String] = []
@Published
var includeAllLanguages: Bool = false {
didSet {
DispatchQueue.main.async {
self.send(.refresh)
}
}
}
@Published
var provider: String? = nil {
didSet {
DispatchQueue.main.async {
self.send(.refresh)
}
}
}
let imageType: ImageType
init(imageType: ImageType, parent: BaseItemDto) {
self.imageType = imageType
super.init(parent: parent)
}
override func get(page: Int) async throws -> [RemoteImageInfo] {
guard let itemID = parent?.id else { return [] }
var parameters = Paths.GetRemoteImagesParameters()
parameters.isIncludeAllLanguages = includeAllLanguages
parameters.limit = pageSize
parameters.providerName = provider
parameters.startIndex = page * pageSize
parameters.type = imageType
let request = Paths.getRemoteImages(itemID: itemID, parameters: parameters)
let response = try await userSession.client.send(request)
await MainActor.run {
providers = response.value.providers ?? []
}
return response.value.images ?? []
}
}

View File

@ -151,6 +151,12 @@ class UserProfileImageViewModel: ViewModel, Eventful, Stateful {
) )
request.headers = ["Content-Type": contentType] request.headers = ["Content-Type": contentType]
guard imageData.count <= 30_000_000 else {
throw JellyfinAPIError(
"This profile image is too large (\(imageData.count.formatted(.byteCount(style: .file)))). The upload limit for images is 30 MB."
)
}
let _ = try await userSession.client.send(request) let _ = try await userSession.client.send(request)
sweepProfileImageCache() sweepProfileImageCache()

View File

@ -22,6 +22,8 @@
4E10C81D2CC046610012CC9F /* UserSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C81C2CC0465F0012CC9F /* UserSection.swift */; }; 4E10C81D2CC046610012CC9F /* UserSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C81C2CC0465F0012CC9F /* UserSection.swift */; };
4E11805F2CBF52380077A588 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; 4E11805F2CBF52380077A588 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; };
4E12F9172CBE9619006C217E /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; }; 4E12F9172CBE9619006C217E /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; };
4E13FAD82D18D5AF007785F6 /* ImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E13FAD72D18D5AD007785F6 /* ImageInfo.swift */; };
4E13FAD92D18D5AF007785F6 /* ImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E13FAD72D18D5AD007785F6 /* ImageInfo.swift */; };
4E14DC032CD43DD2001B621B /* AdminDashboardCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E14DC022CD43DCB001B621B /* AdminDashboardCoordinator.swift */; }; 4E14DC032CD43DD2001B621B /* AdminDashboardCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E14DC022CD43DCB001B621B /* AdminDashboardCoordinator.swift */; };
4E16FD512C0183DB00110147 /* LetterPickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD502C0183DB00110147 /* LetterPickerButton.swift */; }; 4E16FD512C0183DB00110147 /* LetterPickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD502C0183DB00110147 /* LetterPickerButton.swift */; };
4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD522C01840C00110147 /* LetterPickerBar.swift */; }; 4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD522C01840C00110147 /* LetterPickerBar.swift */; };
@ -31,6 +33,8 @@
4E17498F2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */; }; 4E17498F2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */; };
4E182C9C2C94993200FBEFD5 /* ServerTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9B2C94993200FBEFD5 /* ServerTasksView.swift */; }; 4E182C9C2C94993200FBEFD5 /* ServerTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9B2C94993200FBEFD5 /* ServerTasksView.swift */; };
4E182C9F2C94A1E000FBEFD5 /* ServerTaskRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */; }; 4E182C9F2C94A1E000FBEFD5 /* ServerTaskRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */; };
4E1AA0042D0640AA00524970 /* RemoteImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E1AA0032D0640A400524970 /* RemoteImageInfo.swift */; };
4E1AA0052D0640AA00524970 /* RemoteImageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E1AA0032D0640A400524970 /* RemoteImageInfo.swift */; };
4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */; }; 4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */; };
4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; 4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; };
4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; }; 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2182E42CAF67EF0094806B /* PlayMethod.swift */; };
@ -67,8 +71,12 @@
4E35CE6C2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; }; 4E35CE6C2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; };
4E35CE6D2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; }; 4E35CE6D2CBEDB7600DBD886 /* TaskState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */; };
4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */; }; 4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */; };
4E37F6162D17C1860022AADD /* RemoteImageInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E37F6152D17C1710022AADD /* RemoteImageInfoViewModel.swift */; };
4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */; }; 4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */; };
4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */; }; 4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */; };
4E45939E2D04E20000E277E1 /* ItemImagesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E45939D2D04E1E600E277E1 /* ItemImagesViewModel.swift */; };
4E4593A32D04E2B500E277E1 /* ItemImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4593A22D04E2AF00E277E1 /* ItemImagesView.swift */; };
4E4593A62D04E4E300E277E1 /* AddItemImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4593A52D04E4DE00E277E1 /* AddItemImageView.swift */; };
4E49DECB2CE54AA200352DCD /* SessionsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECA2CE54A9200352DCD /* SessionsSection.swift */; }; 4E49DECB2CE54AA200352DCD /* SessionsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECA2CE54A9200352DCD /* SessionsSection.swift */; };
4E49DECD2CE54C7A00352DCD /* PermissionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECC2CE54C7200352DCD /* PermissionSection.swift */; }; 4E49DECD2CE54C7A00352DCD /* PermissionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECC2CE54C7200352DCD /* PermissionSection.swift */; };
4E49DECF2CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */; }; 4E49DECF2CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */; };
@ -78,22 +86,16 @@
4E49DED52CE54D9D00352DCD /* LoginFailurePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */; }; 4E49DED52CE54D9D00352DCD /* LoginFailurePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */; };
4E49DED62CE54D9D00352DCD /* LoginFailurePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */; }; 4E49DED62CE54D9D00352DCD /* LoginFailurePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */; };
4E49DED82CE5509300352DCD /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DED72CE5509000352DCD /* StatusSection.swift */; }; 4E49DED82CE5509300352DCD /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DED72CE5509000352DCD /* StatusSection.swift */; };
4E49DEE02CE55F7F00352DCD /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEDC2CE55F7F00352DCD /* PhotoPicker.swift */; };
4E49DEE12CE55F7F00352DCD /* SquareImageCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEDD2CE55F7F00352DCD /* SquareImageCropView.swift */; };
4E49DEE32CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */; }; 4E49DEE32CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */; };
4E49DEE42CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */; }; 4E49DEE42CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */; };
4E49DEE62CE5616800352DCD /* UserProfileImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */; }; 4E49DEE62CE5616800352DCD /* UserProfileImagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE52CE5616800352DCD /* UserProfileImagePickerView.swift */; };
4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */; }; 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.swift */; };
4E4DAC372D11EE5E00E13FF9 /* SplitLoginWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4DAC362D11EE4F00E13FF9 /* SplitLoginWindowView.swift */; }; 4E4DAC372D11EE5E00E13FF9 /* SplitLoginWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4DAC362D11EE4F00E13FF9 /* SplitLoginWindowView.swift */; };
4E4DAC3D2D11F94400E13FF9 /* LocalServerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4DAC3C2D11F94000E13FF9 /* LocalServerButton.swift */; }; 4E4DAC3D2D11F94400E13FF9 /* LocalServerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4DAC3C2D11F94000E13FF9 /* LocalServerButton.swift */; };
4E4E9C672CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */; }; 4E4E9C672CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */; };
4E4E9C682CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */; };
4E4E9C6A2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.swift */; }; 4E4E9C6A2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.swift */; };
4E4E9C6B2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.swift */; };
4E5071D72CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D62CFCEB6F003FA2AD /* TagEditorViewModel.swift */; }; 4E5071D72CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D62CFCEB6F003FA2AD /* TagEditorViewModel.swift */; };
4E5071D82CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D62CFCEB6F003FA2AD /* TagEditorViewModel.swift */; };
4E5071DA2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */; }; 4E5071DA2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */; };
4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */; };
4E5071E42CFCEFD3003FA2AD /* AddItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */; }; 4E5071E42CFCEFD3003FA2AD /* AddItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */; };
4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */; }; 4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */; };
4E537A842D03D11200659A1A /* ServerUserDeviceAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */; }; 4E537A842D03D11200659A1A /* ServerUserDeviceAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E537A832D03D10B00659A1A /* ServerUserDeviceAccessView.swift */; };
@ -106,7 +108,6 @@
4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; }; 4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; };
4E656C302D0798AA00F993F3 /* ParentalRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */; }; 4E656C302D0798AA00F993F3 /* ParentalRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */; };
4E656C312D0798AA00F993F3 /* ParentalRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */; }; 4E656C312D0798AA00F993F3 /* ParentalRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E656C2F2D0798A900F993F3 /* ParentalRating.swift */; };
4E6619FC2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */; };
4E6619FD2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */; }; 4E6619FD2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */; };
4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A002CEFE39900025C99 /* EditMetadataView.swift */; }; 4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A002CEFE39900025C99 /* EditMetadataView.swift */; };
4E661A0F2CEFE46300025C99 /* SeriesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A0D2CEFE46300025C99 /* SeriesSection.swift */; }; 4E661A0F2CEFE46300025C99 /* SeriesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A0D2CEFE46300025C99 /* SeriesSection.swift */; };
@ -175,6 +176,13 @@
4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */; }; 4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */; };
4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; }; 4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; };
4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */; }; 4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */; };
4EA78B132D29F62E0093BFCE /* ItemImagesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B112D29F6240093BFCE /* ItemImagesCoordinator.swift */; };
4EA78B162D2A0C4A0093BFCE /* ItemImageDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B142D2A0C4A0093BFCE /* ItemImageDetailsView.swift */; };
4EA78B202D2B5AA30093BFCE /* ItemPhotoPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B1F2D2B5A9E0093BFCE /* ItemPhotoPickerView.swift */; };
4EA78B232D2B5CFC0093BFCE /* ItemPhotoCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B222D2B5CEF0093BFCE /* ItemPhotoCropView.swift */; };
4EA78B252D2B5DBD0093BFCE /* ItemImagePickerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B242D2B5DB20093BFCE /* ItemImagePickerCoordinator.swift */; };
4EB132EF2D2CF6D600B5A8E5 /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */; };
4EB132F02D2CF6D600B5A8E5 /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */; };
4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; }; 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; };
4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; }; 4EB1A8CA2C9A766200F43898 /* ActiveSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */; };
4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */; }; 4EB1A8CC2C9B1BA200F43898 /* DestructiveServerTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */; };
@ -219,11 +227,18 @@
4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; }; 4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */; };
4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */; }; 4EE766F52D131FBC009658F0 /* IdentifyItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F42D131FB7009658F0 /* IdentifyItemView.swift */; };
4EE766F72D132054009658F0 /* IdentifyItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */; }; 4EE766F72D132054009658F0 /* IdentifyItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */; };
4EE766F82D132054009658F0 /* IdentifyItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */; };
4EE766FA2D132954009658F0 /* RemoteSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */; }; 4EE766FA2D132954009658F0 /* RemoteSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */; };
4EE766FB2D132954009658F0 /* RemoteSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */; }; 4EE766FB2D132954009658F0 /* RemoteSearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */; };
4EE767082D13403F009658F0 /* RemoteSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */; }; 4EE767082D13403F009658F0 /* RemoteSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */; };
4EE7670A2D135CBA009658F0 /* RemoteSearchResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */; }; 4EE7670A2D135CBA009658F0 /* RemoteSearchResultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */; };
4EECA4E32D2C7D530080A863 /* PhotoPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4E22D2C7D530080A863 /* PhotoPickerView.swift */; };
4EECA4E62D2C7D650080A863 /* PhotoCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4E52D2C7D650080A863 /* PhotoCropView.swift */; };
4EECA4ED2D2C89D70080A863 /* UserProfileImageCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4EC2D2C89D20080A863 /* UserProfileImageCropView.swift */; };
4EECA4EF2D2C9B310080A863 /* ItemImageDetailsHeaderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4EE2D2C9B260080A863 /* ItemImageDetailsHeaderSection.swift */; };
4EECA4F12D2C9E860080A863 /* ItemImageDetailsDetailsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4F02D2C9E7B0080A863 /* ItemImageDetailsDetailsSection.swift */; };
4EECA4F32D2CA5A10080A863 /* ItemImageDetailsDeleteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4F22D2CA59B0080A863 /* ItemImageDetailsDeleteButton.swift */; };
4EECA4F52D2CAA380080A863 /* RatingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4F42D2CAA350080A863 /* RatingType.swift */; };
4EECA4F62D2CAA380080A863 /* RatingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EECA4F42D2CAA350080A863 /* RatingType.swift */; };
4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87462CBF824B002354D2 /* DeviceRow.swift */; }; 4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87462CBF824B002354D2 /* DeviceRow.swift */; };
4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; }; 4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; };
4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */; }; 4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */; };
@ -522,7 +537,6 @@
E11BDF972B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; }; E11BDF972B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; };
E11BDF982B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; }; E11BDF982B865F550045C54A /* ItemTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11BDF962B865F550045C54A /* ItemTag.swift */; };
E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */; }; E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */; };
E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C15342BF7C505006BC9B6 /* UserProfileImageCoordinator.swift */; };
E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; }; E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */; };
E11CEB8B28998552003E74C7 /* View-iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8A28998552003E74C7 /* View-iOS.swift */; }; E11CEB8B28998552003E74C7 /* View-iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8A28998552003E74C7 /* View-iOS.swift */; };
E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* Font.swift */; }; E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11CEB8C28999B4A003E74C7 /* Font.swift */; };
@ -810,8 +824,6 @@
E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */; }; E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */; };
E17FB55928C125E900311DFE /* StudiosHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55828C125E900311DFE /* StudiosHStack.swift */; }; E17FB55928C125E900311DFE /* StudiosHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55828C125E900311DFE /* StudiosHStack.swift */; };
E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55A28C1266400311DFE /* GenresHStack.swift */; }; E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FB55A28C1266400311DFE /* GenresHStack.swift */; };
E1803EA12BFBD6CF0039F90E /* Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1803EA02BFBD6CF0039F90E /* Hashable.swift */; };
E1803EA22BFBD6CF0039F90E /* Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1803EA02BFBD6CF0039F90E /* Hashable.swift */; };
E18121062CBE428000682985 /* ChevronButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528728FD229500600579 /* ChevronButton.swift */; }; E18121062CBE428000682985 /* ChevronButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A1528728FD229500600579 /* ChevronButton.swift */; };
E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */; }; E18295E429CAC6F100F91ED0 /* BasicNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */; };
E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E18443CA2A037773002DDDC8 /* UDPBroadcast */; }; E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E18443CA2A037773002DDDC8 /* UDPBroadcast */; };
@ -1205,6 +1217,7 @@
4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceNameSection.swift; sourceTree = "<group>"; }; 4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceNameSection.swift; sourceTree = "<group>"; };
4E10C81C2CC0465F0012CC9F /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = "<group>"; }; 4E10C81C2CC0465F0012CC9F /* UserSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSection.swift; sourceTree = "<group>"; };
4E12F9152CBE9615006C217E /* DeviceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceType.swift; sourceTree = "<group>"; }; 4E12F9152CBE9615006C217E /* DeviceType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceType.swift; sourceTree = "<group>"; };
4E13FAD72D18D5AD007785F6 /* ImageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageInfo.swift; sourceTree = "<group>"; };
4E14DC022CD43DCB001B621B /* AdminDashboardCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDashboardCoordinator.swift; sourceTree = "<group>"; }; 4E14DC022CD43DCB001B621B /* AdminDashboardCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDashboardCoordinator.swift; sourceTree = "<group>"; };
4E16FD502C0183DB00110147 /* LetterPickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerButton.swift; sourceTree = "<group>"; }; 4E16FD502C0183DB00110147 /* LetterPickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerButton.swift; sourceTree = "<group>"; };
4E16FD522C01840C00110147 /* LetterPickerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerBar.swift; sourceTree = "<group>"; }; 4E16FD522C01840C00110147 /* LetterPickerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerBar.swift; sourceTree = "<group>"; };
@ -1212,6 +1225,7 @@
4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = "<group>"; }; 4E17498D2CC00A2E00DD07D1 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = "<group>"; };
4E182C9B2C94993200FBEFD5 /* ServerTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTasksView.swift; sourceTree = "<group>"; }; 4E182C9B2C94993200FBEFD5 /* ServerTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTasksView.swift; sourceTree = "<group>"; };
4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskRow.swift; sourceTree = "<group>"; }; 4E182C9E2C94A1E000FBEFD5 /* ServerTaskRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerTaskRow.swift; sourceTree = "<group>"; };
4E1AA0032D0640A400524970 /* RemoteImageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageInfo.swift; sourceTree = "<group>"; };
4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = "<group>"; }; 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = "<group>"; };
4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = "<group>"; }; 4E2182E42CAF67EF0094806B /* PlayMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayMethod.swift; sourceTree = "<group>"; };
4E2470062D078DD7009139D8 /* ServerUserParentalRatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserParentalRatingView.swift; sourceTree = "<group>"; }; 4E2470062D078DD7009139D8 /* ServerUserParentalRatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserParentalRatingView.swift; sourceTree = "<group>"; };
@ -1238,18 +1252,20 @@
4E35CE682CBED95F00DBD886 /* DayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeek.swift; sourceTree = "<group>"; }; 4E35CE682CBED95F00DBD886 /* DayOfWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeek.swift; sourceTree = "<group>"; };
4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskState.swift; sourceTree = "<group>"; }; 4E35CE6B2CBEDB7300DBD886 /* TaskState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskState.swift; sourceTree = "<group>"; };
4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysViewModel.swift; sourceTree = "<group>"; }; 4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysViewModel.swift; sourceTree = "<group>"; };
4E37F6152D17C1710022AADD /* RemoteImageInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImageInfoViewModel.swift; sourceTree = "<group>"; };
4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsSection.swift; sourceTree = "<group>"; }; 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsSection.swift; sourceTree = "<group>"; };
4E3A24DB2CFE35CC0083A72C /* NameInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameInput.swift; sourceTree = "<group>"; }; 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameInput.swift; sourceTree = "<group>"; };
4E45939D2D04E1E600E277E1 /* ItemImagesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImagesViewModel.swift; sourceTree = "<group>"; };
4E4593A22D04E2AF00E277E1 /* ItemImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImagesView.swift; sourceTree = "<group>"; };
4E4593A52D04E4DE00E277E1 /* AddItemImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddItemImageView.swift; sourceTree = "<group>"; };
4E49DECA2CE54A9200352DCD /* SessionsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsSection.swift; sourceTree = "<group>"; }; 4E49DECA2CE54A9200352DCD /* SessionsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsSection.swift; sourceTree = "<group>"; };
4E49DECC2CE54C7200352DCD /* PermissionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionSection.swift; sourceTree = "<group>"; }; 4E49DECC2CE54C7200352DCD /* PermissionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionSection.swift; sourceTree = "<group>"; };
4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaxBitratePolicy.swift; sourceTree = "<group>"; }; 4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaxBitratePolicy.swift; sourceTree = "<group>"; };
4E49DED12CE54D6900352DCD /* ActiveSessionsPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsPolicy.swift; sourceTree = "<group>"; }; 4E49DED12CE54D6900352DCD /* ActiveSessionsPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsPolicy.swift; sourceTree = "<group>"; };
4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFailurePolicy.swift; sourceTree = "<group>"; }; 4E49DED42CE54D9C00352DCD /* LoginFailurePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFailurePolicy.swift; sourceTree = "<group>"; };
4E49DED72CE5509000352DCD /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = "<group>"; }; 4E49DED72CE5509000352DCD /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = "<group>"; };
4E49DEDC2CE55F7F00352DCD /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = "<group>"; };
4E49DEDD2CE55F7F00352DCD /* SquareImageCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SquareImageCropView.swift; sourceTree = "<group>"; };
4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPlayUserAccessType.swift; sourceTree = "<group>"; }; 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPlayUserAccessType.swift; sourceTree = "<group>"; };
4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImagePicker.swift; sourceTree = "<group>"; }; 4E49DEE52CE5616800352DCD /* UserProfileImagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImagePickerView.swift; sourceTree = "<group>"; };
4E4DAC362D11EE4F00E13FF9 /* SplitLoginWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitLoginWindowView.swift; sourceTree = "<group>"; }; 4E4DAC362D11EE4F00E13FF9 /* SplitLoginWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitLoginWindowView.swift; sourceTree = "<group>"; };
4E4DAC3C2D11F94000E13FF9 /* LocalServerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalServerButton.swift; sourceTree = "<group>"; }; 4E4DAC3C2D11F94000E13FF9 /* LocalServerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalServerButton.swift; sourceTree = "<group>"; };
4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioEditorViewModel.swift; sourceTree = "<group>"; }; 4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioEditorViewModel.swift; sourceTree = "<group>"; };
@ -1321,6 +1337,12 @@
4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = "<group>"; }; 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = "<group>"; };
4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysRow.swift; sourceTree = "<group>"; }; 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysRow.swift; sourceTree = "<group>"; };
4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserViewModel.swift; sourceTree = "<group>"; }; 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserViewModel.swift; sourceTree = "<group>"; };
4EA78B112D29F6240093BFCE /* ItemImagesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImagesCoordinator.swift; sourceTree = "<group>"; };
4EA78B142D2A0C4A0093BFCE /* ItemImageDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImageDetailsView.swift; sourceTree = "<group>"; };
4EA78B1F2D2B5A9E0093BFCE /* ItemPhotoPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPhotoPickerView.swift; sourceTree = "<group>"; };
4EA78B222D2B5CEF0093BFCE /* ItemPhotoCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPhotoCropView.swift; sourceTree = "<group>"; };
4EA78B242D2B5DB20093BFCE /* ItemImagePickerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImagePickerCoordinator.swift; sourceTree = "<group>"; };
4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageType.swift; sourceTree = "<group>"; };
4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = "<group>"; }; 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = "<group>"; };
4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = "<group>"; }; 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = "<group>"; };
4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = "<group>"; }; 4EB1A8CB2C9B1B9700F43898 /* DestructiveServerTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveServerTask.swift; sourceTree = "<group>"; };
@ -1362,6 +1384,13 @@
4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResult.swift; sourceTree = "<group>"; }; 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResult.swift; sourceTree = "<group>"; };
4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultRow.swift; sourceTree = "<group>"; }; 4EE767072D134020009658F0 /* RemoteSearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultRow.swift; sourceTree = "<group>"; };
4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultView.swift; sourceTree = "<group>"; }; 4EE767092D135CAC009658F0 /* RemoteSearchResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSearchResultView.swift; sourceTree = "<group>"; };
4EECA4E22D2C7D530080A863 /* PhotoPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPickerView.swift; sourceTree = "<group>"; };
4EECA4E52D2C7D650080A863 /* PhotoCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCropView.swift; sourceTree = "<group>"; };
4EECA4EC2D2C89D20080A863 /* UserProfileImageCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImageCropView.swift; sourceTree = "<group>"; };
4EECA4EE2D2C9B260080A863 /* ItemImageDetailsHeaderSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImageDetailsHeaderSection.swift; sourceTree = "<group>"; };
4EECA4F02D2C9E7B0080A863 /* ItemImageDetailsDetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImageDetailsDetailsSection.swift; sourceTree = "<group>"; };
4EECA4F22D2CA59B0080A863 /* ItemImageDetailsDeleteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImageDetailsDeleteButton.swift; sourceTree = "<group>"; };
4EECA4F42D2CAA350080A863 /* RatingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatingType.swift; sourceTree = "<group>"; };
4EED87462CBF824B002354D2 /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = "<group>"; }; 4EED87462CBF824B002354D2 /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = "<group>"; };
4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = "<group>"; }; 4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = "<group>"; };
4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = "<group>"; }; 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = "<group>"; };
@ -1753,7 +1782,6 @@
E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = "<group>"; }; E17FB55628C1256400311DFE /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = "<group>"; };
E17FB55828C125E900311DFE /* StudiosHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudiosHStack.swift; sourceTree = "<group>"; }; E17FB55828C125E900311DFE /* StudiosHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudiosHStack.swift; sourceTree = "<group>"; };
E17FB55A28C1266400311DFE /* GenresHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenresHStack.swift; sourceTree = "<group>"; }; E17FB55A28C1266400311DFE /* GenresHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenresHStack.swift; sourceTree = "<group>"; };
E1803EA02BFBD6CF0039F90E /* Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hashable.swift; sourceTree = "<group>"; };
E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = "<group>"; }; E185920528CDAA6400326F80 /* CastAndCrewHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastAndCrewHStack.swift; sourceTree = "<group>"; };
E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarItemsHStack.swift; sourceTree = "<group>"; }; E185920728CDAAA200326F80 /* SimilarItemsHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarItemsHStack.swift; sourceTree = "<group>"; };
E185920928CEF23A00326F80 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = "<group>"; }; E185920928CEF23A00326F80 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = "<group>"; };
@ -2276,13 +2304,25 @@
path = ServerLogsView; path = ServerLogsView;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
4E3766192D2144BA00C5D7A5 /* ItemElements */ = { 4E37F6182D17EB220022AADD /* ItemImages */ = {
isa = PBXGroup;
children = (
4E4593A52D04E4DE00E277E1 /* AddItemImageView.swift */,
4EA78B152D2A0C4A0093BFCE /* ItemImageDetailsView */,
4E4593A22D04E2AF00E277E1 /* ItemImagesView.swift */,
4EA78B1E2D2B5A960093BFCE /* ItemPhotoPickerView */,
);
path = ItemImages;
sourceTree = "<group>";
};
4E37F6192D17EB3C0022AADD /* ItemMetadata */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4E5071E22CFCEFC3003FA2AD /* AddItemElementView */, 4E5071E22CFCEFC3003FA2AD /* AddItemElementView */,
4E31EFA22CFFFB410053DFE7 /* EditItemElementView */, 4E31EFA22CFFFB410053DFE7 /* EditItemElementView */,
4E6619FF2CEFE39000025C99 /* EditMetadataView */,
); );
path = ItemElements; path = ItemMetadata;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */ = { 4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */ = {
@ -2297,8 +2337,7 @@
4E49DEDE2CE55F7F00352DCD /* Components */ = { 4E49DEDE2CE55F7F00352DCD /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4E49DEDC2CE55F7F00352DCD /* PhotoPicker.swift */, 4EECA4EC2D2C89D20080A863 /* UserProfileImageCropView.swift */,
4E49DEDD2CE55F7F00352DCD /* SquareImageCropView.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2307,7 +2346,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4E49DEDE2CE55F7F00352DCD /* Components */, 4E49DEDE2CE55F7F00352DCD /* Components */,
4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */, 4E49DEE52CE5616800352DCD /* UserProfileImagePickerView.swift */,
); );
path = UserProfileImagePicker; path = UserProfileImagePicker;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2531,10 +2570,10 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
4E8F74A62CE03D4C00CC8969 /* Components */, 4E8F74A62CE03D4C00CC8969 /* Components */,
4E6619FF2CEFE39000025C99 /* EditMetadataView */, 4E37F6182D17EB220022AADD /* ItemImages */,
4E37F6192D17EB3C0022AADD /* ItemMetadata */,
4EE766F32D131F6E009658F0 /* IdentifyItemView */, 4EE766F32D131F6E009658F0 /* IdentifyItemView */,
4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */, 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */,
4E3766192D2144BA00C5D7A5 /* ItemElements */,
); );
path = ItemEditorView; path = ItemEditorView;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2552,8 +2591,10 @@
children = ( children = (
4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */, 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */,
4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */, 4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */,
4E45939D2D04E1E600E277E1 /* ItemImagesViewModel.swift */,
4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */, 4EE766F62D132043009658F0 /* IdentifyItemViewModel.swift */,
4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */, 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */,
4E37F6152D17C1710022AADD /* RemoteImageInfoViewModel.swift */,
); );
path = ItemAdministration; path = ItemAdministration;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2648,6 +2689,42 @@
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
4EA78B152D2A0C4A0093BFCE /* ItemImageDetailsView */ = {
isa = PBXGroup;
children = (
4EA78B1B2D2A266A0093BFCE /* Components */,
4EA78B142D2A0C4A0093BFCE /* ItemImageDetailsView.swift */,
);
path = ItemImageDetailsView;
sourceTree = "<group>";
};
4EA78B1B2D2A266A0093BFCE /* Components */ = {
isa = PBXGroup;
children = (
4EECA4F22D2CA59B0080A863 /* ItemImageDetailsDeleteButton.swift */,
4EECA4F02D2C9E7B0080A863 /* ItemImageDetailsDetailsSection.swift */,
4EECA4EE2D2C9B260080A863 /* ItemImageDetailsHeaderSection.swift */,
);
path = Components;
sourceTree = "<group>";
};
4EA78B1E2D2B5A960093BFCE /* ItemPhotoPickerView */ = {
isa = PBXGroup;
children = (
4EA78B212D2B5CDD0093BFCE /* Components */,
4EA78B1F2D2B5A9E0093BFCE /* ItemPhotoPickerView.swift */,
);
path = ItemPhotoPickerView;
sourceTree = "<group>";
};
4EA78B212D2B5CDD0093BFCE /* Components */ = {
isa = PBXGroup;
children = (
4EA78B222D2B5CEF0093BFCE /* ItemPhotoCropView.swift */,
);
path = Components;
sourceTree = "<group>";
};
4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */ = { 4EB1A8CF2C9B2FA200F43898 /* ActiveSessionsView */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2828,6 +2905,23 @@
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
4EECA4E12D2C7D450080A863 /* PhotoPickerView */ = {
isa = PBXGroup;
children = (
4EECA4E42D2C7D570080A863 /* Components */,
4EECA4E22D2C7D530080A863 /* PhotoPickerView.swift */,
);
path = PhotoPickerView;
sourceTree = "<group>";
};
4EECA4E42D2C7D570080A863 /* Components */ = {
isa = PBXGroup;
children = (
4EECA4E52D2C7D650080A863 /* PhotoCropView.swift */,
);
path = Components;
sourceTree = "<group>";
};
4EED87472CBF824B002354D2 /* Components */ = { 4EED87472CBF824B002354D2 /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -3397,7 +3491,6 @@
E133328729538D8D00EE76AB /* Files.swift */, E133328729538D8D00EE76AB /* Files.swift */,
E11CEB8C28999B4A003E74C7 /* Font.swift */, E11CEB8C28999B4A003E74C7 /* Font.swift */,
E10432F52BE4426F006FF9DD /* FormatStyle.swift */, E10432F52BE4426F006FF9DD /* FormatStyle.swift */,
E1803EA02BFBD6CF0039F90E /* Hashable.swift */,
E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */, E1E6C44A29AED2B70064123F /* HorizontalAlignment.swift */,
E139CC1E28EC83E400688DE2 /* Int.swift */, E139CC1E28EC83E400688DE2 /* Int.swift */,
E1AD105226D96D5F003E4A08 /* JellyfinAPI */, E1AD105226D96D5F003E4A08 /* JellyfinAPI */,
@ -3406,6 +3499,7 @@
E1A505692D0B733F007EE305 /* Optional.swift */, E1A505692D0B733F007EE305 /* Optional.swift */,
E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */, E1B4E4362CA7795200DC49DE /* OrderedDictionary.swift */,
E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */, E1B490432967E26300D3EDCE /* PersistentLogHandler.swift */,
4EECA4F42D2CAA350080A863 /* RatingType.swift */,
E1B5861129E32EEF00E45D6E /* Sequence.swift */, E1B5861129E32EEF00E45D6E /* Sequence.swift */,
E145EB442BE0AD4E003BF6F3 /* Set.swift */, E145EB442BE0AD4E003BF6F3 /* Set.swift */,
621338922660107500A81A2A /* String.swift */, 621338922660107500A81A2A /* String.swift */,
@ -3450,6 +3544,8 @@
62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */, 62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */,
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */, 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */,
4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */, 4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */,
4EA78B242D2B5DB20093BFCE /* ItemImagePickerCoordinator.swift */,
4EA78B112D29F6240093BFCE /* ItemImagesCoordinator.swift */,
6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */, 6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */,
E102312B2BCF8A08009D71FC /* LiveTVCoordinator */, E102312B2BCF8A08009D71FC /* LiveTVCoordinator */,
C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */, C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */,
@ -3953,6 +4049,7 @@
E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */, E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */,
E103DF922BCF2F23000229B2 /* MediaView */, E103DF922BCF2F23000229B2 /* MediaView */,
E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */, E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */,
4EECA4E12D2C7D450080A863 /* PhotoPickerView */,
E10231342BCF8A3C009D71FC /* ProgramsView */, E10231342BCF8A3C009D71FC /* ProgramsView */,
E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */,
4EF10D4C2CE2EC5A000ED5F5 /* ResetUserPasswordView */, 4EF10D4C2CE2EC5A000ED5F5 /* ResetUserPasswordView */,
@ -4100,8 +4197,8 @@
children = ( children = (
E1763A282BF3046A004DF6AB /* AddUserButton.swift */, E1763A282BF3046A004DF6AB /* AddUserButton.swift */,
E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */, E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */,
E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */,
BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */, BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */,
E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
@ -4490,6 +4587,8 @@
E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */, E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */,
4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */, 4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */,
E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */, E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */,
4E13FAD72D18D5AD007785F6 /* ImageInfo.swift */,
4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */,
E1D842902933F87500D1041A /* ItemFields.swift */, E1D842902933F87500D1041A /* ItemFields.swift */,
E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */, E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */,
E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */, E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */,
@ -4504,6 +4603,7 @@
4EFE0C7C2D0156A500D4834D /* PersonKind.swift */, 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */,
E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */, E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */,
4E2182E42CAF67EF0094806B /* PlayMethod.swift */, 4E2182E42CAF67EF0094806B /* PlayMethod.swift */,
4E1AA0032D0640A400524970 /* RemoteImageInfo.swift */,
4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */, 4EE766F92D13294F009658F0 /* RemoteSearchResult.swift */,
4E35CE652CBED8B300DBD886 /* ServerTicks.swift */, 4E35CE652CBED8B300DBD886 /* ServerTicks.swift */,
4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */, 4EB4ECE22CBEFC49002FF2FC /* SessionInfo.swift */,
@ -5312,11 +5412,11 @@
E1D4BF8B2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, E1D4BF8B2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */,
E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */, E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
E146A9D92BE6E9830034DA1E /* StoredValue.swift in Sources */, E146A9D92BE6E9830034DA1E /* StoredValue.swift in Sources */,
4E13FAD82D18D5AF007785F6 /* ImageInfo.swift in Sources */,
E13DD3FA2717E961009D4DAF /* SelectUserViewModel.swift in Sources */, E13DD3FA2717E961009D4DAF /* SelectUserViewModel.swift in Sources */,
C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */, C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */,
E13D98EE2D0664C1005FE96D /* NotificationSet.swift in Sources */, E13D98EE2D0664C1005FE96D /* NotificationSet.swift in Sources */,
E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */, E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */,
4E6619FC2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */,
E1CB757F2C80F28F00217C76 /* SubtitleProfile.swift in Sources */, E1CB757F2C80F28F00217C76 /* SubtitleProfile.swift in Sources */,
E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */, E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */,
E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */, E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */,
@ -5361,7 +5461,6 @@
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
E1575E95293E7B1E001665B1 /* Font.swift in Sources */, E1575E95293E7B1E001665B1 /* Font.swift in Sources */,
4E49DED02CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */, 4E49DED02CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */,
E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */,
E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */, 4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */,
4E24ECFC2D076F6200A473A9 /* ListRowCheckbox.swift in Sources */, 4E24ECFC2D076F6200A473A9 /* ListRowCheckbox.swift in Sources */,
@ -5379,7 +5478,6 @@
E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */, E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */,
E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */, E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */,
E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */, E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */,
4EE766F82D132054009658F0 /* IdentifyItemViewModel.swift in Sources */,
E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */,
E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */,
BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */,
@ -5408,7 +5506,6 @@
E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */, E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */,
E1CB75782C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */, E1CB75782C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */,
E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */, E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */,
E1803EA22BFBD6CF0039F90E /* Hashable.swift in Sources */,
4E49DEE32CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */, 4E49DEE32CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */,
E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */, E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */,
E1575E93293E7B1E001665B1 /* Double.swift in Sources */, E1575E93293E7B1E001665B1 /* Double.swift in Sources */,
@ -5444,6 +5541,7 @@
E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */,
E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */, E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */,
E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */, E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */,
4EECA4F62D2CAA380080A863 /* RatingType.swift in Sources */,
E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */, E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */,
E12CC1CB28D1333400678D5D /* CinematicResumeItemView.swift in Sources */, E12CC1CB28D1333400678D5D /* CinematicResumeItemView.swift in Sources */,
4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */, 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */,
@ -5466,7 +5564,6 @@
E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */,
E17DC74B2BE740D900B42379 /* StoredValues+Server.swift in Sources */, E17DC74B2BE740D900B42379 /* StoredValues+Server.swift in Sources */,
4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */, 4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */,
4E5071D82CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */,
E10E842A29A587110064EA49 /* LoadingView.swift in Sources */, E10E842A29A587110064EA49 /* LoadingView.swift in Sources */,
E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */,
E13316FF2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */, E13316FF2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */,
@ -5494,7 +5591,6 @@
E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */,
E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */, E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */,
4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */,
4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */, 4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */,
4E7315752D1485C900EA2A95 /* UserProfileImage.swift in Sources */, 4E7315752D1485C900EA2A95 /* UserProfileImage.swift in Sources */,
E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */, E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */,
@ -5531,7 +5627,6 @@
E11042762B8013DF00821020 /* Stateful.swift in Sources */, E11042762B8013DF00821020 /* Stateful.swift in Sources */,
091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */,
E1575E66293E77B5001665B1 /* Poster.swift in Sources */, E1575E66293E77B5001665B1 /* Poster.swift in Sources */,
4E4E9C682CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */,
E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */, E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */,
E19D41B42BF2C0020082B8B2 /* StoredValues+Temp.swift in Sources */, E19D41B42BF2C0020082B8B2 /* StoredValues+Temp.swift in Sources */,
4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */, 4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */,
@ -5573,7 +5668,6 @@
E148128628C15475003B8787 /* SortOrder+ItemSortOrder.swift in Sources */, E148128628C15475003B8787 /* SortOrder+ItemSortOrder.swift in Sources */,
E1CB75722C80E71800217C76 /* DirectPlayProfile.swift in Sources */, E1CB75722C80E71800217C76 /* DirectPlayProfile.swift in Sources */,
E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */,
4E4E9C6B2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */,
E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */, E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */,
E133328929538D8D00EE76AB /* Files.swift in Sources */, E133328929538D8D00EE76AB /* Files.swift in Sources */,
E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */, E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */,
@ -5643,6 +5737,7 @@
E18E021C2887492B0022598C /* BlurView.swift in Sources */, E18E021C2887492B0022598C /* BlurView.swift in Sources */,
E187F7682B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, E187F7682B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */,
E1E6C44729AECD5D0064123F /* PlayPreviousItemActionButton.swift in Sources */, E1E6C44729AECD5D0064123F /* PlayPreviousItemActionButton.swift in Sources */,
4E1AA0052D0640AA00524970 /* RemoteImageInfo.swift in Sources */,
E1A5056B2D0B733F007EE305 /* Optional.swift in Sources */, E1A5056B2D0B733F007EE305 /* Optional.swift in Sources */,
E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */, E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */,
E1CB75832C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */, E1CB75832C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */,
@ -5670,6 +5765,7 @@
E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */,
E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */, E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */,
E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */, E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */,
4EB132F02D2CF6D600B5A8E5 /* ImageType.swift in Sources */,
E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
E1CB75702C80E66700217C76 /* CommaStringBuilder.swift in Sources */, E1CB75702C80E66700217C76 /* CommaStringBuilder.swift in Sources */,
5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */,
@ -5755,7 +5851,7 @@
4EB7C8D52CCED6E7000CC011 /* AddServerUserView.swift in Sources */, 4EB7C8D52CCED6E7000CC011 /* AddServerUserView.swift in Sources */,
E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E102314A2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */,
E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */, E1721FAA28FB7CAC00762992 /* CompactTimeStamp.swift in Sources */,
E1803EA12BFBD6CF0039F90E /* Hashable.swift in Sources */, 4E4593A32D04E2B500E277E1 /* ItemImagesView.swift in Sources */,
4E699BB92CB33FC2007CBD5D /* HomeSection.swift in Sources */, 4E699BB92CB33FC2007CBD5D /* HomeSection.swift in Sources */,
62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */,
E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */, E12CC1B128D1008F00678D5D /* NextUpView.swift in Sources */,
@ -5835,6 +5931,7 @@
C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */, C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */,
E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */,
E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */,
4E37F6162D17C1860022AADD /* RemoteImageInfoViewModel.swift in Sources */,
4E10C8172CC0455A0012CC9F /* CompatibilitiesSection.swift in Sources */, 4E10C8172CC0455A0012CC9F /* CompatibilitiesSection.swift in Sources */,
4EE7670A2D135CBA009658F0 /* RemoteSearchResultView.swift in Sources */, 4EE7670A2D135CBA009658F0 /* RemoteSearchResultView.swift in Sources */,
E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */,
@ -5846,6 +5943,7 @@
4E661A2E2CEFE77700025C99 /* MetadataField.swift in Sources */, 4E661A2E2CEFE77700025C99 /* MetadataField.swift in Sources */,
4EFAC1332D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift in Sources */, 4EFAC1332D1E8C6B00E40880 /* AddServerUserAccessTagsView.swift in Sources */,
E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
4E1AA0042D0640AA00524970 /* RemoteImageInfo.swift in Sources */,
4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */, 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */,
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */,
@ -5878,6 +5976,7 @@
E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */, E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */,
4E8F74B22CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */, 4E8F74B22CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */,
E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */, E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */,
4E4593A62D04E4E300E277E1 /* AddItemImageView.swift in Sources */,
E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */, E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */,
E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */,
4E2AC4C52C6C492700DD600D /* MediaContainer.swift in Sources */, 4E2AC4C52C6C492700DD600D /* MediaContainer.swift in Sources */,
@ -5889,6 +5988,7 @@
E1CB75752C80EAFA00217C76 /* ArrayBuilder.swift in Sources */, E1CB75752C80EAFA00217C76 /* ArrayBuilder.swift in Sources */,
E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */, E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */,
E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */, E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */,
4EECA4F52D2CAA380080A863 /* RatingType.swift in Sources */,
4E49DECF2CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */, 4E49DECF2CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */,
E18ACA922A15A32F00BB4F35 /* (null) in Sources */, E18ACA922A15A32F00BB4F35 /* (null) in Sources */,
E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */, E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */,
@ -5915,6 +6015,7 @@
E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */, E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */,
E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */, E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */,
E1D90D762C051D44000EA787 /* BackPort+ScrollIndicatorVisibility.swift in Sources */, E1D90D762C051D44000EA787 /* BackPort+ScrollIndicatorVisibility.swift in Sources */,
4EECA4E62D2C7D650080A863 /* PhotoCropView.swift in Sources */,
6264E88C273850380081A12A /* Strings.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */,
E145EB252BE055AD003BF6F3 /* ServerResponse.swift in Sources */, E145EB252BE055AD003BF6F3 /* ServerResponse.swift in Sources */,
E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */,
@ -5922,6 +6023,7 @@
62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */,
E10E67B72CF515130095365B /* Binding.swift in Sources */, E10E67B72CF515130095365B /* Binding.swift in Sources */,
E119696A2CC99EA9001A58BE /* ServerTaskProgressSection.swift in Sources */, E119696A2CC99EA9001A58BE /* ServerTaskProgressSection.swift in Sources */,
4EA78B132D29F62E0093BFCE /* ItemImagesCoordinator.swift in Sources */,
E1BAFE102BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift in Sources */, E1BAFE102BE921270069C4D7 /* SwiftfinApp+ValueObservation.swift in Sources */,
E1ED7FDE2CAA641F00ACB6E3 /* ListTitleSection.swift in Sources */, E1ED7FDE2CAA641F00ACB6E3 /* ListTitleSection.swift in Sources */,
62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
@ -5940,8 +6042,6 @@
E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */,
4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */, 4ED25CA42D07E4990010333C /* EditAccessScheduleRow.swift in Sources */,
4E49DEE02CE55F7F00352DCD /* PhotoPicker.swift in Sources */,
4E49DEE12CE55F7F00352DCD /* SquareImageCropView.swift in Sources */,
4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */, 4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */,
4E90F7652CC72B1F00417C31 /* EditServerTaskView.swift in Sources */, 4E90F7652CC72B1F00417C31 /* EditServerTaskView.swift in Sources */,
4E90F7662CC72B1F00417C31 /* LastErrorSection.swift in Sources */, 4E90F7662CC72B1F00417C31 /* LastErrorSection.swift in Sources */,
@ -5976,6 +6076,7 @@
E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */, E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */,
E1401CB129386C9200E8B599 /* UIColor.swift in Sources */, E1401CB129386C9200E8B599 /* UIColor.swift in Sources */,
E1E2F8452B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, E1E2F8452B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */,
4EECA4F32D2CA5A10080A863 /* ItemImageDetailsDeleteButton.swift in Sources */,
E18E01AB288746AF0022598C /* PillHStack.swift in Sources */, E18E01AB288746AF0022598C /* PillHStack.swift in Sources */,
E19070492C84F2BB0004600E /* ButtonStyle-iOS.swift in Sources */, E19070492C84F2BB0004600E /* ButtonStyle-iOS.swift in Sources */,
E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */, E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */,
@ -5985,12 +6086,14 @@
E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */, E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */,
E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */, E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */,
4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */, 4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */,
4E45939E2D04E20000E277E1 /* ItemImagesViewModel.swift in Sources */,
C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */, C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */,
E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, E18E01AD288746AF0022598C /* DotHStack.swift in Sources */,
E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */,
E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */, E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */,
4E01446D2D0292E200193038 /* Trie.swift in Sources */, 4E01446D2D0292E200193038 /* Trie.swift in Sources */,
E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */,
4EA78B162D2A0C4A0093BFCE /* ItemImageDetailsView.swift in Sources */,
4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */, 4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */,
E145EB482BE0C136003BF6F3 /* ScrollIfLargerThanContainerModifier.swift in Sources */, E145EB482BE0C136003BF6F3 /* ScrollIfLargerThanContainerModifier.swift in Sources */,
E1CB757C2C80F00D00217C76 /* TranscodingProfile.swift in Sources */, E1CB757C2C80F00D00217C76 /* TranscodingProfile.swift in Sources */,
@ -6038,6 +6141,7 @@
C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */, C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */,
4E17498E2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */, 4E17498E2CC00A3100DD07D1 /* DeviceInfo.swift in Sources */,
4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */, 4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */,
4E13FAD92D18D5AF007785F6 /* ImageInfo.swift in Sources */,
4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */, 4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */,
E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */,
E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */,
@ -6121,6 +6225,7 @@
E1A1528D28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E1A1528D28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */,
4E35CE642CBED69600DBD886 /* TaskTriggerType.swift in Sources */, 4E35CE642CBED69600DBD886 /* TaskTriggerType.swift in Sources */,
E18E01EE288747230022598C /* AboutView.swift in Sources */, E18E01EE288747230022598C /* AboutView.swift in Sources */,
4EB132EF2D2CF6D600B5A8E5 /* ImageType.swift in Sources */,
62E632E0267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, 62E632E0267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */,
E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */, E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */,
E1549662296CA2EF00C4EF88 /* UserSession.swift in Sources */, E1549662296CA2EF00C4EF88 /* UserSession.swift in Sources */,
@ -6129,13 +6234,15 @@
E10B1ECD2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */, E10B1ECD2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */,
E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */, E1401CA92938140700E8B599 /* DarkAppIcon.swift in Sources */,
E1A1529028FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */, E1A1529028FD23D600600579 /* PlaybackSettingsCoordinator.swift in Sources */,
4EA78B252D2B5DBD0093BFCE /* ItemImagePickerCoordinator.swift in Sources */,
E11042752B8013DF00821020 /* Stateful.swift in Sources */, E11042752B8013DF00821020 /* Stateful.swift in Sources */,
E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */,
E12376AE2A33D680001F5B44 /* AboutView+Card.swift in Sources */, E12376AE2A33D680001F5B44 /* AboutView+Card.swift in Sources */,
E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */, E1A2C154279A7D5A005EC829 /* UIApplication.swift in Sources */,
4E49DEE62CE5616800352DCD /* UserProfileImagePicker.swift in Sources */, 4E49DEE62CE5616800352DCD /* UserProfileImagePickerView.swift in Sources */,
4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */, 4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */,
E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */, E11C15352BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */,
4EA78B232D2B5CFC0093BFCE /* ItemPhotoCropView.swift in Sources */,
E1D8428F2933F2D900D1041A /* MediaSourceInfo.swift in Sources */, E1D8428F2933F2D900D1041A /* MediaSourceInfo.swift in Sources */,
E1BDF2EC2952290200CC0294 /* AspectFillActionButton.swift in Sources */, E1BDF2EC2952290200CC0294 /* AspectFillActionButton.swift in Sources */,
BD0BA22B2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */, BD0BA22B2AD6503B00306A8D /* OnlineVideoPlayerManager.swift in Sources */,
@ -6174,6 +6281,7 @@
4EFAC1302D1E2EB900E40880 /* EditAccessTagRow.swift in Sources */, 4EFAC1302D1E2EB900E40880 /* EditAccessTagRow.swift in Sources */,
E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */, E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */, E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */,
4EECA4E32D2C7D530080A863 /* PhotoPickerView.swift in Sources */,
E1ED7FDC2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */, E1ED7FDC2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */,
E149CCAD2BE6ECC8008B9331 /* Storable.swift in Sources */, E149CCAD2BE6ECC8008B9331 /* Storable.swift in Sources */,
E1CB75792C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */, E1CB75792C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */,
@ -6226,6 +6334,7 @@
E1F5CF092CB0A04500607465 /* Text.swift in Sources */, E1F5CF092CB0A04500607465 /* Text.swift in Sources */,
4E182C9F2C94A1E000FBEFD5 /* ServerTaskRow.swift in Sources */, 4E182C9F2C94A1E000FBEFD5 /* ServerTaskRow.swift in Sources */,
E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */, E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */,
4EA78B202D2B5AA30093BFCE /* ItemPhotoPickerView.swift in Sources */,
E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */, E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */,
E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */, E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */,
E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */, E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */,
@ -6245,6 +6354,7 @@
C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */,
E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */,
E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */,
4EECA4F12D2C9E860080A863 /* ItemImageDetailsDetailsSection.swift in Sources */,
E1D37F482B9C648E00343D2B /* MaxHeightText.swift in Sources */, E1D37F482B9C648E00343D2B /* MaxHeightText.swift in Sources */,
BD3957752C112A330078CEF8 /* ButtonSection.swift in Sources */, BD3957752C112A330078CEF8 /* ButtonSection.swift in Sources */,
E1ED7FE32CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */, E1ED7FE32CAA6BAF00ACB6E3 /* ServerLogsViewModel.swift in Sources */,
@ -6265,6 +6375,7 @@
E1763A712BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */, E1763A712BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */,
5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */,
E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */, E1D8429529346C6400D1041A /* BasicStepper.swift in Sources */,
4EECA4ED2D2C89D70080A863 /* UserProfileImageCropView.swift in Sources */,
E18E01EA288747230022598C /* MovieItemView.swift in Sources */, E18E01EA288747230022598C /* MovieItemView.swift in Sources */,
6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */,
E164A7F42BE4736300A54B18 /* SignOutIntervalSection.swift in Sources */, E164A7F42BE4736300A54B18 /* SignOutIntervalSection.swift in Sources */,
@ -6293,6 +6404,7 @@
E13DD4022717EE79009D4DAF /* SelectUserCoordinator.swift in Sources */, E13DD4022717EE79009D4DAF /* SelectUserCoordinator.swift in Sources */,
E11245B128D919CD00D8A977 /* Overlay.swift in Sources */, E11245B128D919CD00D8A977 /* Overlay.swift in Sources */,
E145EB4D2BE1688E003BF6F3 /* SwiftinStore+UserState.swift in Sources */, E145EB4D2BE1688E003BF6F3 /* SwiftinStore+UserState.swift in Sources */,
4EECA4EF2D2C9B310080A863 /* ItemImageDetailsHeaderSection.swift in Sources */,
53EE24E6265060780068F029 /* SearchView.swift in Sources */, 53EE24E6265060780068F029 /* SearchView.swift in Sources */,
E164A8152BE58C2F00A54B18 /* V2AnyData.swift in Sources */, E164A8152BE58C2F00A54B18 /* V2AnyData.swift in Sources */,
E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */, E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */,

View File

@ -13,38 +13,53 @@ import SwiftUI
// Meant to be used within `List` or `Form` // Meant to be used within `List` or `Form`
struct ListRowButton: View { struct ListRowButton: View {
let title: String private let title: String
let action: () -> Void private let role: ButtonRole?
private let action: () -> Void
init(_ title: String, action: @escaping () -> Void) { init(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) {
self.title = title self.title = title
self.role = role
self.action = action self.action = action
} }
var body: some View { var body: some View {
Button(title, action: action) Button(title, role: role, action: action)
.font(.body.weight(.bold))
.buttonStyle(ListRowButtonStyle()) .buttonStyle(ListRowButtonStyle())
.listRowInsets(.init(.zero)) .listRowInsets(.zero)
} }
} }
// TODO: implement `role`
private struct ListRowButtonStyle: ButtonStyle { private struct ListRowButtonStyle: ButtonStyle {
@Environment(\.isEnabled) @Environment(\.isEnabled)
private var 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 {
return AnyShapeStyle(Color.red.opacity(0.2))
} else {
return isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray)
}
}
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
ZStack { ZStack {
Rectangle() Rectangle()
.foregroundStyle(isEnabled ? AnyShapeStyle(HierarchicalShapeStyle.secondary) : AnyShapeStyle(Color.gray)) .fill(secondaryStyle(configuration: configuration))
configuration.label configuration.label
.foregroundStyle(.primary) .foregroundStyle(primaryStyle(configuration: configuration))
} }
.opacity(configuration.isPressed ? 0.75 : 1) .opacity(configuration.isPressed ? 0.75 : 1)
.frame(maxWidth: .infinity) .font(.body.weight(.bold))
.listRowInsets(.zero)
} }
} }

View File

@ -25,6 +25,7 @@ struct ListTitleSection: View {
Text(title) Text(title)
.font(.title3) .font(.title3)
.fontWeight(.semibold) .fontWeight(.semibold)
.multilineTextAlignment(.center)
if let description { if let description {
Text(description) Text(description)

View File

@ -20,6 +20,7 @@ extension ButtonStyle where Self == ToolbarPillButtonStyle {
} }
} }
// TODO: don't take `Color`, take generic `ShapeStyle`
struct ToolbarPillButtonStyle: ButtonStyle { struct ToolbarPillButtonStyle: ButtonStyle {
@Environment(\.isEnabled) @Environment(\.isEnabled)

View File

@ -74,13 +74,19 @@ struct IdentifyItemView: View {
.navigationTitle(L10n.identify) .navigationTitle(L10n.identify)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(viewModel.state == .updating) .navigationBarBackButtonHidden(viewModel.state == .updating)
.sheet(item: $selectedResult) { result in .sheet(item: $selectedResult) {
RemoteSearchResultView(result: result) { selectedResult = nil
selectedResult = nil } content: { result in
viewModel.send(.update(result)) RemoteSearchResultView(
} onClose: { result: result,
selectedResult = nil onSave: {
} selectedResult = nil
viewModel.send(.update(result))
},
onClose: {
selectedResult = nil
}
)
} }
.onReceive(viewModel.events) { events in .onReceive(viewModel.events) { events in
switch events { switch events {

View File

@ -104,6 +104,10 @@ struct ItemEditorView: View {
router.route(to: \.identifyItem, viewModel.item) router.route(to: \.identifyItem, viewModel.item)
} }
} }
ChevronButton(L10n.images)
.onSelect {
router.route(to: \.editImages, ItemImagesViewModel(item: viewModel.item))
}
ChevronButton(L10n.metadata) ChevronButton(L10n.metadata)
.onSelect { .onSelect {
router.route(to: \.editMetadata, viewModel.item) router.route(to: \.editMetadata, viewModel.item)

View File

@ -0,0 +1,214 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import BlurHashKit
import CollectionVGrid
import JellyfinAPI
import SwiftUI
// TODO: different layouts per image type
// - also based on iOS vs iPadOS
struct AddItemImageView: View {
// MARK: - Observed, & Environment Objects
@EnvironmentObject
private var router: ItemImagesCoordinator.Router
@ObservedObject
private var viewModel: ItemImagesViewModel
@StateObject
private var remoteImageInfoViewModel: RemoteImageInfoViewModel
// MARK: - Dialog State
@State
private var selectedImage: RemoteImageInfo?
@State
private var error: Error?
// MARK: - Collection Layout
@State
private var layout: CollectionVGridLayout = .minWidth(150)
// MARK: - Initializer
init(viewModel: ItemImagesViewModel, imageType: ImageType) {
self.viewModel = viewModel
self._remoteImageInfoViewModel = StateObject(
wrappedValue: RemoteImageInfoViewModel(
imageType: imageType,
parent: viewModel.item
)
)
}
// MARK: - Body
var body: some View {
ZStack {
switch remoteImageInfoViewModel.state {
case .initial, .refreshing:
DelayedProgressView()
case .content:
gridView
case let .error(error):
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
}
}
.animation(.linear(duration: 0.1), value: remoteImageInfoViewModel.state)
.navigationTitle(remoteImageInfoViewModel.imageType.displayTitle)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(viewModel.backgroundStates.contains(.updating))
.navigationBarMenuButton(isLoading: viewModel.backgroundStates.contains(.updating)) {
Button {
remoteImageInfoViewModel.includeAllLanguages.toggle()
} label: {
if remoteImageInfoViewModel.includeAllLanguages {
Label(L10n.allLanguages, systemImage: "checkmark")
} else {
Text(L10n.allLanguages)
}
}
if remoteImageInfoViewModel.providers.isNotEmpty {
Menu {
Button {
remoteImageInfoViewModel.provider = nil
} label: {
if remoteImageInfoViewModel.provider == nil {
Label(L10n.all, systemImage: "checkmark")
} else {
Text(L10n.all)
}
}
ForEach(remoteImageInfoViewModel.providers, id: \.self) { provider in
Button {
remoteImageInfoViewModel.provider = provider
} label: {
if remoteImageInfoViewModel.provider == provider {
Label(provider, systemImage: "checkmark")
} else {
Text(provider)
}
}
}
} label: {
Text(L10n.provider)
Text(remoteImageInfoViewModel.provider ?? L10n.all)
}
}
}
.sheet(item: $selectedImage) {
selectedImage = nil
} content: { remoteImageInfo in
ItemImageDetailsView(
viewModel: viewModel,
imageSource: ImageSource(url: remoteImageInfo.url?.url),
width: remoteImageInfo.width,
height: remoteImageInfo.height,
language: remoteImageInfo.language,
provider: remoteImageInfo.providerName,
rating: remoteImageInfo.communityRating,
ratingVotes: remoteImageInfo.voteCount,
onClose: {
selectedImage = nil
},
onSave: {
viewModel.send(.setImage(remoteImageInfo))
selectedImage = nil
}
)
}
.onFirstAppear {
remoteImageInfoViewModel.send(.refresh)
}
.onReceive(viewModel.events) { event in
switch event {
case .updated:
UIDevice.feedback(.success)
router.pop()
case let .error(eventError):
UIDevice.feedback(.error)
error = eventError
}
}
.errorMessage($error)
}
// MARK: - Content Grid View
@ViewBuilder
private var gridView: some View {
if remoteImageInfoViewModel.elements.isEmpty {
Text(L10n.none)
} else {
CollectionVGrid(
uniqueElements: remoteImageInfoViewModel.elements,
layout: layout
) { image in
imageButton(image)
}
.onReachedBottomEdge(offset: .offset(300)) {
remoteImageInfoViewModel.send(.getNextPage)
}
}
}
// MARK: - Poster Image Button
@ViewBuilder
private func imageButton(_ image: RemoteImageInfo) -> some View {
Button {
selectedImage = image
} label: {
posterImage(
image,
posterStyle: (image.height ?? 0) > (image.width ?? 0) ? .portrait : .landscape
)
}
}
// MARK: - Poster Image
@ViewBuilder
private func posterImage(
_ posterImageInfo: RemoteImageInfo?,
posterStyle: PosterDisplayType
) -> some View {
ZStack {
Color.secondarySystemFill
.frame(maxWidth: .infinity, maxHeight: .infinity)
ImageView(posterImageInfo?.url?.url)
.placeholder { source in
if let blurHash = source.blurHash {
BlurHashView(blurHash: blurHash)
.scaledToFit()
} else {
Image(systemName: "photo")
}
}
.failure {
Image(systemName: "photo")
}
.pipeline(.Swiftfin.other)
.foregroundStyle(.secondary)
.font(.headline)
}
.posterStyle(posterStyle)
}
}

View File

@ -0,0 +1,50 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension ItemImageDetailsView {
struct DeleteButton: View {
// MARK: - Delete Action
let onDelete: () -> Void
// MARK: - Dialog State
@State
private var isPresentingConfirmation: Bool = false
// MARK: - Body
var body: some View {
ListRowButton(L10n.delete, role: .destructive) {
isPresentingConfirmation = true
}
.confirmationDialog(
L10n.delete,
isPresented: $isPresentingConfirmation,
titleVisibility: .visible
) {
Button(
L10n.delete,
role: .destructive,
action: onDelete
)
Button(L10n.cancel, role: .cancel) {
isPresentingConfirmation = false
}
} message: {
Text(L10n.deleteItemConfirmationMessage)
}
}
}
}

View File

@ -0,0 +1,103 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension ItemImageDetailsView {
struct DetailsSection: View {
// MARK: - Image Details Variables
private let index: Int?
private let language: String?
private let width: Int?
private let height: Int?
private let provider: String?
// MARK: - Image Ratings Variables
private let rating: Double?
private let ratingVotes: Int?
// MARK: - Image Source Variable
private let url: URL?
// MARK: - Initializer
init(
url: URL? = nil,
index: Int? = nil,
language: String? = nil,
width: Int? = nil,
height: Int? = nil,
provider: String? = nil,
rating: Double? = nil,
ratingType: RatingType? = nil,
ratingVotes: Int? = nil
) {
self.url = url
self.index = index
self.language = language
self.width = width
self.height = height
self.provider = provider
self.rating = rating
self.ratingVotes = ratingVotes
}
// MARK: - Body
var body: some View {
Section(L10n.details) {
if let provider {
TextPairView(leading: L10n.provider, trailing: provider)
}
if let language {
TextPairView(leading: L10n.language, trailing: language)
}
if let width, let height {
TextPairView(
leading: L10n.dimensions,
trailing: "\(width) x \(height)"
)
}
if let index {
TextPairView(leading: L10n.index, trailing: index.description)
}
}
if let rating {
Section(L10n.ratings) {
TextPairView(leading: L10n.rating, trailing: rating.formatted(.number.precision(.fractionLength(2))))
if let ratingVotes {
TextPairView(L10n.votes, value: Text(ratingVotes, format: .number))
}
}
}
if let url {
Section {
ChevronButton(
L10n.imageSource,
external: true
)
.onSelect {
UIApplication.shared.open(url)
}
}
}
}
}
}

View File

@ -0,0 +1,43 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension ItemImageDetailsView {
struct HeaderSection: View {
// MARK: - Image Info
let imageSource: ImageSource
let posterType: PosterDisplayType
// MARK: - Body
var body: some View {
Section {
ImageView(imageSource)
.placeholder { _ in
Image(systemName: "photo")
}
.failure {
Image(systemName: "photo")
}
.pipeline(.Swiftfin.other)
}
.scaledToFit()
.frame(maxHeight: 300)
.posterStyle(posterType)
.frame(maxWidth: .infinity)
.listRowBackground(Color.clear)
.listRowCornerRadius(0)
.listRowInsets(.zero)
}
}
}

View File

@ -0,0 +1,126 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct ItemImageDetailsView: View {
@Environment(\.isEditing)
private var isEditing
// MARK: - State, Observed, & Environment Objects
@EnvironmentObject
private var router: BasicNavigationViewCoordinator.Router
@ObservedObject
private var viewModel: ItemImagesViewModel
// MARK: - Image Variable
private let imageSource: ImageSource
// MARK: - Description Variables
private let index: Int?
private let width: Int?
private let height: Int?
private let language: String?
private let provider: String?
private let rating: Double?
private let ratingVotes: Int?
// MARK: - Image Actions
private let onClose: () -> Void
private let onSave: (() -> Void)?
private let onDelete: (() -> Void)?
// MARK: - Initializer
init(
viewModel: ItemImagesViewModel,
imageSource: ImageSource,
index: Int? = nil,
width: Int? = nil,
height: Int? = nil,
language: String? = nil,
provider: String? = nil,
rating: Double? = nil,
ratingVotes: Int? = nil,
onClose: @escaping () -> Void,
onSave: (() -> Void)? = nil,
onDelete: (() -> Void)? = nil
) {
self.viewModel = viewModel
self.imageSource = imageSource
self.index = index
self.width = width
self.height = height
self.language = language
self.provider = provider
self.rating = rating
self.ratingVotes = ratingVotes
self.onClose = onClose
self.onSave = onSave
self.onDelete = onDelete
}
// MARK: - Body
var body: some View {
NavigationView {
contentView
.navigationTitle(L10n.image)
.navigationBarTitleDisplayMode(.inline)
.navigationBarCloseButton {
onClose()
}
.topBarTrailing {
if viewModel.backgroundStates.contains(.updating) {
ProgressView()
}
if let onSave {
Button(L10n.save, action: onSave)
.buttonStyle(.toolbarPill)
}
}
}
}
// MARK: - Content View
@ViewBuilder
private var contentView: some View {
List {
HeaderSection(
imageSource: imageSource,
posterType: height ?? 0 > width ?? 0 ? .portrait : .landscape
)
DetailsSection(
url: imageSource.url,
index: index,
language: language,
width: width,
height: height,
provider: provider,
rating: rating,
ratingVotes: ratingVotes
)
if isEditing, let onDelete {
DeleteButton {
onDelete()
}
}
}
}
}

View File

@ -0,0 +1,222 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import JellyfinAPI
import SwiftUI
struct ItemImagesView: View {
// MARK: - Defaults
@Default(.accentColor)
private var accentColor
// MARK: - Observed & Environment Objects
@EnvironmentObject
private var router: ItemImagesCoordinator.Router
@StateObject
var viewModel: ItemImagesViewModel
// MARK: - Dialog State
@State
private var selectedImage: ImageInfo?
@State
private var selectedType: ImageType?
@State
private var isFilePickerPresented = false
// MARK: - Error State
@State
private var error: Error?
// MARK: - Body
var body: some View {
ZStack {
switch viewModel.state {
case .content:
imageView
case .initial:
DelayedProgressView()
case let .error(error):
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
}
}
.navigationTitle(L10n.images)
.navigationBarTitleDisplayMode(.inline)
.onFirstAppear {
viewModel.send(.refresh)
}
.navigationBarCloseButton {
router.dismissCoordinator()
}
.sheet(item: $selectedImage) {
selectedImage = nil
} content: { imageInfo in
ItemImageDetailsView(
viewModel: viewModel,
imageSource: imageInfo.itemImageSource(
itemID: viewModel.item.id!,
client: viewModel.userSession.client
),
index: imageInfo.imageIndex,
width: imageInfo.width,
height: imageInfo.height,
onClose: {
selectedImage = nil
},
onDelete: {
viewModel.send(.deleteImage(imageInfo))
selectedImage = nil
}
)
.environment(\.isEditing, true)
}
.fileImporter(
isPresented: $isFilePickerPresented,
allowedContentTypes: [.png, .jpeg, .heic],
allowsMultipleSelection: false
) {
switch $0 {
case let .success(urls):
if let file = urls.first, let type = selectedType {
viewModel.send(.uploadFile(file: file, type: type))
selectedType = nil
}
case let .failure(fileError):
error = fileError
selectedType = nil
}
}
.onReceive(viewModel.events) { event in
switch event {
case .updated: ()
case let .error(eventError):
self.error = eventError
}
}
.errorMessage($error)
}
// MARK: - Image View
@ViewBuilder
private var imageView: some View {
ScrollView {
ForEach(ImageType.allCases.sorted(using: \.rawValue), id: \.self) { imageType in
Section {
imageScrollView(for: imageType)
RowDivider()
.padding(.vertical, 16)
} header: {
sectionHeader(for: imageType)
}
}
}
}
// MARK: - Image Scroll View
@ViewBuilder
private func imageScrollView(for imageType: ImageType) -> some View {
let images = viewModel.images[imageType] ?? []
if images.isNotEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(images, id: \.self) { imageInfo in
imageButton(imageInfo: imageInfo) {
selectedImage = imageInfo
}
}
}
.edgePadding(.horizontal)
}
}
}
// MARK: - Section Header
@ViewBuilder
private func sectionHeader(for imageType: ImageType) -> some View {
HStack {
Text(imageType.displayTitle)
.font(.headline)
Spacer()
Menu(L10n.options, systemImage: "plus") {
Button(L10n.search, systemImage: "magnifyingglass") {
router.route(
to: \.addImage,
imageType
)
}
Divider()
Button(L10n.uploadFile, systemImage: "document.badge.plus") {
selectedType = imageType
isFilePickerPresented = true
}
Button(L10n.uploadPhoto, systemImage: "photo.badge.plus") {
router.route(to: \.photoPicker, imageType)
}
}
.font(.body)
.labelStyle(.iconOnly)
.backport
.fontWeight(.semibold)
.foregroundStyle(accentColor)
}
.edgePadding(.horizontal)
}
// MARK: - Image Button
// TODO: instead of using `posterStyle`, should be sized based on
// the image type and just ignore and poster styling
@ViewBuilder
private func imageButton(
imageInfo: ImageInfo,
onSelect: @escaping () -> Void
) -> some View {
Button(action: onSelect) {
ZStack {
Color.secondarySystemFill
ImageView(
imageInfo.itemImageSource(
itemID: viewModel.item.id!,
client: viewModel.userSession.client
)
)
.placeholder { _ in
Image(systemName: "photo")
}
.failure {
Image(systemName: "photo")
}
.pipeline(.Swiftfin.other)
}
.posterStyle(imageInfo.height ?? 0 > imageInfo.width ?? 0 ? .portrait : .landscape)
.frame(maxHeight: 150)
.posterShadow()
}
}
}

View File

@ -0,0 +1,59 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import Mantis
import SwiftUI
struct ItemPhotoCropView: View {
// MARK: - State, Observed, & Environment Objects
@EnvironmentObject
private var router: ItemImagePickerCoordinator.Router
@ObservedObject
var viewModel: ItemImagesViewModel
// MARK: - Image Variable
let image: UIImage
let type: ImageType
// MARK: - Error State
@State
private var error: Error?
// MARK: - Body
var body: some View {
PhotoCropView(
isSaving: viewModel.backgroundStates.contains(.updating),
image: image,
cropShape: .rect,
presetRatio: .canUseMultiplePresetFixedRatio()
) {
viewModel.send(.uploadImage(image: $0, type: type))
} onCancel: {
router.dismissCoordinator()
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.interactiveDismissDisabled(viewModel.backgroundStates.contains(.updating))
.navigationBarBackButtonHidden(viewModel.backgroundStates.contains(.updating))
.onReceive(viewModel.events) { event in
switch event {
case let .error(eventError):
error = eventError
case .updated:
router.dismissCoordinator()
}
}
.errorMessage($error)
}
}

View File

@ -0,0 +1,27 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import SwiftUI
struct ItemImagePicker: View {
// MARK: - Observed, & Environment Objects
@EnvironmentObject
private var router: ItemImagePickerCoordinator.Router
// MARK: - Body
var body: some View {
PhotoPickerView {
router.route(to: \.cropImage, $0)
} onCancel: {
router.dismissCoordinator()
}
}
}

View File

@ -0,0 +1,141 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Combine
import Defaults
import JellyfinAPI
import SwiftUI
struct AddItemElementView<Element: Hashable>: View {
// MARK: - Defaults
@Default(.accentColor)
private var accentColor
// MARK: - Environment & Observed Objects
@EnvironmentObject
private var router: BasicNavigationViewCoordinator.Router
@ObservedObject
var viewModel: ItemEditorViewModel<Element>
// MARK: - Elements Variables
let type: ItemArrayElements
@State
private var id: String?
@State
private var name: String = ""
@State
private var personKind: PersonKind = .unknown
@State
private var personRole: String = ""
// MARK: - Trie Data Loaded
@State
private var loaded: Bool = false
// MARK: - Error State
@State
private var error: Error?
// MARK: - Name is Valid
private var isValid: Bool {
name.isNotEmpty
}
// MARK: - Name Already Exists
private var itemAlreadyExists: Bool {
viewModel.trie.contains(key: name.localizedLowercase)
}
// MARK: - Body
var body: some View {
ZStack {
switch viewModel.state {
case .initial, .content, .updating:
contentView
case let .error(error):
ErrorView(error: error)
}
}
.navigationTitle(type.displayTitle)
.navigationBarTitleDisplayMode(.inline)
.navigationBarCloseButton {
router.dismissCoordinator()
}
.topBarTrailing {
if viewModel.backgroundStates.contains(.loading) {
ProgressView()
}
Button(L10n.save) {
viewModel.send(.add([type.createElement(
name: name,
id: id,
personRole: personRole.isEmpty ? (personKind == .unknown ? nil : personKind.rawValue) : personRole,
personKind: personKind.rawValue
)]))
}
.buttonStyle(.toolbarPill)
.disabled(!isValid)
}
.onFirstAppear {
viewModel.send(.load)
}
.onChange(of: name) { _ in
if !viewModel.backgroundStates.contains(.loading) {
viewModel.send(.search(name))
}
}
.onReceive(viewModel.events) { event in
switch event {
case .updated:
UIDevice.feedback(.success)
router.dismissCoordinator()
case .loaded:
loaded = true
viewModel.send(.search(name))
case let .error(eventError):
UIDevice.feedback(.error)
error = eventError
}
}
.errorMessage($error)
}
// MARK: - Content View
private var contentView: some View {
List {
NameInput(
name: $name,
personKind: $personKind,
personRole: $personRole,
type: type,
itemAlreadyExists: itemAlreadyExists
)
SearchResultsSection(
name: $name,
id: $id,
type: type,
population: viewModel.matches,
isSearching: viewModel.backgroundStates.contains(.searching)
)
}
}
}

View File

@ -0,0 +1,87 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension AddItemElementView {
struct NameInput: View {
// MARK: - Element Variables
@Binding
var name: String
@Binding
var personKind: PersonKind
@Binding
var personRole: String
let type: ItemArrayElements
let itemAlreadyExists: Bool
// MARK: - Body
var body: some View {
nameView
if type == .people {
personView
}
}
// MARK: - Name View
private var nameView: some View {
Section {
TextField(L10n.name, text: $name)
.autocorrectionDisabled()
} header: {
Text(L10n.name)
} footer: {
if name.isEmpty || name == "" {
Label(
L10n.required,
systemImage: "exclamationmark.circle.fill"
)
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
} else {
if itemAlreadyExists {
Label(
L10n.existsOnServer,
systemImage: "checkmark.circle.fill"
)
.labelStyle(.sectionFooterWithImage(imageStyle: .green))
} else {
Label(
L10n.willBeCreatedOnServer,
systemImage: "checkmark.seal.fill"
)
.labelStyle(.sectionFooterWithImage(imageStyle: .blue))
}
}
}
}
// MARK: - Person View
var personView: some View {
Section {
Picker(L10n.type, selection: $personKind) {
ForEach(PersonKind.allCases, id: \.self) { kind in
Text(kind.displayTitle).tag(kind)
}
}
if personKind == PersonKind.actor {
TextField(L10n.role, text: $personRole)
.autocorrectionDisabled()
}
}
}
}
}

View File

@ -0,0 +1,112 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
extension AddItemElementView {
struct SearchResultsSection: View {
// MARK: - Element Variables
@Binding
var name: String
@Binding
var id: String?
// MARK: - Element Search Variables
let type: ItemArrayElements
let population: [Element]
// TODO: Why doesn't environment(\.isSearching) work?
let isSearching: Bool
// MARK: - Body
var body: some View {
if name.isNotEmpty {
Section {
if population.isNotEmpty {
resultsView
.animation(.easeInOut, value: population.count)
} else if !isSearching {
noResultsView
.transition(.opacity)
.animation(.easeInOut, value: population.count)
}
} header: {
HStack {
Text(L10n.existingItems)
if isSearching {
DelayedProgressView()
} else {
Text("-")
Text(population.count.description)
}
}
.animation(.easeInOut, value: isSearching)
}
}
}
// MARK: - No Results View
private var noResultsView: some View {
Text(L10n.none)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
// MARK: - Results View
private var resultsView: some View {
ForEach(population, id: \.self) { result in
Button {
name = type.getName(for: result)
id = type.getId(for: result)
} label: {
labelView(result)
}
.foregroundStyle(.primary)
.disabled(name == type.getName(for: result))
.transition(.opacity.combined(with: .move(edge: .top)))
.animation(.easeInOut, value: population.count)
}
}
// MARK: - Label View
@ViewBuilder
private func labelView(_ match: Element) -> some View {
switch type {
case .people:
let person = match as! BaseItemPerson
HStack {
ZStack {
Color.clear
ImageView(person.portraitImageSources(maxWidth: 30))
.failure {
SystemImageContentView(systemName: "person.fill")
}
}
.posterStyle(.portrait)
.frame(width: 30, height: 90)
.padding(.horizontal)
Text(type.getName(for: match))
.frame(maxWidth: .infinity, alignment: .leading)
}
default:
Text(type.getName(for: match))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}

View File

@ -0,0 +1,113 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import JellyfinAPI
import SwiftUI
extension EditItemElementView {
struct EditItemElementRow: View {
// MARK: - Enviroment Variables
@Environment(\.isEditing)
var isEditing
@Environment(\.isSelected)
var isSelected
// MARK: - Metadata Variables
let item: Element
let type: ItemArrayElements
// MARK: - Row Actions
let onSelect: () -> Void
let onDelete: () -> Void
// MARK: - Body
var body: some View {
ListRow {
if type == .people {
personImage
}
} content: {
rowContent
}
.onSelect(perform: onSelect)
.isSeparatorVisible(false)
.swipeActions {
Button(L10n.delete, systemImage: "trash", action: onDelete)
.tint(.red)
}
}
// MARK: - Row Content
@ViewBuilder
private var rowContent: some View {
HStack {
VStack(alignment: .leading) {
Text(type.getName(for: item))
.foregroundStyle(
isEditing ? (isSelected ? .primary : .secondary) : .primary
)
.font(.headline)
.lineLimit(1)
if type == .people {
let person = (item as! BaseItemPerson)
TextPairView(
leading: person.type ?? .emptyDash,
trailing: person.role ?? .emptyDash
)
.foregroundStyle(
isEditing ? (isSelected ? .primary : .secondary) : .primary,
.secondary
)
.font(.subheadline)
.lineLimit(1)
}
}
if isEditing {
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.resizable()
.aspectRatio(1, contentMode: .fit)
.frame(width: 24, height: 24)
.foregroundStyle(isSelected ? Color.accentColor : .secondary)
}
}
}
// MARK: - Person Image
@ViewBuilder
private var personImage: some View {
let person = (item as! BaseItemPerson)
ZStack {
Color.clear
ImageView(person.portraitImageSources(maxWidth: 30))
.failure {
SystemImageContentView(systemName: "person.fill")
}
}
.posterStyle(.portrait)
.posterShadow()
.frame(width: 30, height: 90)
.padding(.horizontal)
}
}
}

View File

@ -0,0 +1,274 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Combine
import Defaults
import JellyfinAPI
import SwiftUI
struct EditItemElementView<Element: Hashable>: View {
// MARK: - Defaults
@Default(.accentColor)
private var accentColor
// MARK: - Observed & Environment Objects
@EnvironmentObject
private var router: ItemEditorCoordinator.Router
@ObservedObject
var viewModel: ItemEditorViewModel<Element>
// MARK: - Elements
@State
private var elements: [Element]
// MARK: - Type & Route
private let type: ItemArrayElements
private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel<Element>) -> Void
// MARK: - Dialog States
@State
private var isPresentingDeleteConfirmation = false
@State
private var isPresentingDeleteSelectionConfirmation = false
// MARK: - Editing States
@State
private var selectedElements: Set<Element> = []
@State
private var isEditing: Bool = false
@State
private var isReordering: Bool = false
// MARK: - Error State
@State
private var error: Error?
// MARK: - Initializer
init(
viewModel: ItemEditorViewModel<Element>,
type: ItemArrayElements,
route: @escaping (ItemEditorCoordinator.Router, ItemEditorViewModel<Element>) -> Void
) {
self.viewModel = viewModel
self.type = type
self.route = route
self.elements = type.getElement(for: viewModel.item)
}
// MARK: - Body
var body: some View {
ZStack {
switch viewModel.state {
case .initial, .content, .updating:
contentView
case let .error(error):
errorView(with: error)
}
}
.navigationBarTitle(type.displayTitle)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(isEditing || isReordering)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
if isEditing {
navigationBarSelectView
}
}
ToolbarItem(placement: .topBarTrailing) {
if isEditing || isReordering {
Button(L10n.cancel) {
if isEditing {
isEditing.toggle()
}
if isReordering {
elements = type.getElement(for: viewModel.item)
isReordering.toggle()
}
UIDevice.impact(.light)
selectedElements.removeAll()
}
.buttonStyle(.toolbarPill)
.foregroundStyle(accentColor)
}
}
ToolbarItem(placement: .bottomBar) {
if isEditing {
Button(L10n.delete) {
isPresentingDeleteSelectionConfirmation = true
}
.buttonStyle(.toolbarPill(.red))
.disabled(selectedElements.isEmpty)
.frame(maxWidth: .infinity, alignment: .trailing)
}
if isReordering {
Button(L10n.save) {
viewModel.send(.reorder(elements))
isReordering = false
}
.buttonStyle(.toolbarPill)
.disabled(type.getElement(for: viewModel.item) == elements)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.navigationBarMenuButton(
isLoading: viewModel.backgroundStates.contains(.refreshing),
isHidden: isEditing || isReordering
) {
Button(L10n.add, systemImage: "plus") {
route(router, viewModel)
}
if elements.isNotEmpty == true {
Button(L10n.edit, systemImage: "checkmark.circle") {
isEditing = true
}
Button(L10n.reorder, systemImage: "arrow.up.arrow.down") {
isReordering = true
}
}
}
.onReceive(viewModel.events) { events in
switch events {
case let .error(eventError):
error = eventError
default:
break
}
}
.errorMessage($error)
.confirmationDialog(
L10n.delete,
isPresented: $isPresentingDeleteSelectionConfirmation,
titleVisibility: .visible
) {
deleteSelectedConfirmationActions
} message: {
Text(L10n.deleteSelectedConfirmation)
}
.confirmationDialog(
L10n.delete,
isPresented: $isPresentingDeleteConfirmation,
titleVisibility: .visible
) {
deleteConfirmationActions
} message: {
Text(L10n.deleteItemConfirmation)
}
.onNotification(.itemMetadataDidChange) { _ in
self.elements = type.getElement(for: self.viewModel.item)
}
}
// MARK: - Select/Remove All Button
@ViewBuilder
private var navigationBarSelectView: some View {
let isAllSelected = selectedElements.count == (elements.count)
Button(isAllSelected ? L10n.removeAll : L10n.selectAll) {
selectedElements = isAllSelected ? [] : Set(elements)
}
.buttonStyle(.toolbarPill)
.disabled(!isEditing)
.foregroundStyle(accentColor)
}
// MARK: - ErrorView
@ViewBuilder
private func errorView(with error: some Error) -> some View {
ErrorView(error: error)
.onRetry {
viewModel.send(.load)
}
}
// MARK: - Content View
private var contentView: some View {
List {
InsetGroupedListHeader(type.displayTitle, description: type.description)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.vertical, 24)
if elements.isNotEmpty {
ForEach(elements, id: \.self) { element in
EditItemElementRow(
item: element,
type: type,
onSelect: {
if isEditing {
selectedElements.toggle(value: element)
}
},
onDelete: {
selectedElements.toggle(value: element)
isPresentingDeleteConfirmation = true
}
)
.environment(\.isEditing, isEditing)
.environment(\.isSelected, selectedElements.contains(element))
.listRowInsets(.edgeInsets)
}
.onMove { source, destination in
guard isReordering else { return }
elements.move(fromOffsets: source, toOffset: destination)
}
} else {
Text(L10n.none)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.listRowSeparator(.hidden)
.listRowInsets(.zero)
}
}
.listStyle(.plain)
.environment(\.editMode, isReordering ? .constant(.active) : .constant(.inactive))
}
// MARK: - Delete Selected Confirmation Actions
@ViewBuilder
private var deleteSelectedConfirmationActions: some View {
Button(L10n.cancel, role: .cancel) {}
Button(L10n.confirm, role: .destructive) {
let elementsToRemove = elements.filter { selectedElements.contains($0) }
viewModel.send(.remove(elementsToRemove))
selectedElements.removeAll()
isEditing = false
}
}
// MARK: - Delete Single Confirmation Actions
@ViewBuilder
private var deleteConfirmationActions: some View {
Button(L10n.cancel, role: .cancel) {}
Button(L10n.delete, role: .destructive) {
if let elementToRemove = selectedElements.first, selectedElements.count == 1 {
viewModel.send(.remove([elementToRemove]))
selectedElements.removeAll()
isEditing = false
}
}
}
}

View File

@ -0,0 +1,161 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Mantis
import SwiftUI
struct PhotoCropView: View {
// MARK: - State, Observed, & Environment Objects
@StateObject
private var proxy: _PhotoCropView.Proxy = .init()
// MARK: - Image Variable
let isSaving: Bool
let image: UIImage
let cropShape: Mantis.CropShapeType
let presetRatio: Mantis.PresetFixedRatioType
let onSave: (UIImage) -> Void
let onCancel: () -> Void
// MARK: - Body
var body: some View {
_PhotoCropView(
initialImage: image,
cropShape: cropShape,
presetRatio: presetRatio,
proxy: proxy,
onImageCropped: onSave
)
.topBarTrailing {
Button(L10n.rotate, systemImage: "rotate.right") {
proxy.rotate()
}
if isSaving {
Button(L10n.cancel, action: onCancel)
.buttonStyle(.toolbarPill(.red))
} else {
Button(L10n.save) {
proxy.crop()
}
.buttonStyle(.toolbarPill)
}
}
.toolbar {
ToolbarItem(placement: .principal) {
if isSaving {
ProgressView()
} else {
Button(L10n.reset) {
proxy.reset()
}
.foregroundStyle(.yellow)
.disabled(isSaving)
}
}
}
.ignoresSafeArea()
.background {
Color.black
}
}
}
// MARK: - Photo Crop View
private struct _PhotoCropView: UIViewControllerRepresentable {
class Proxy: ObservableObject {
weak var cropViewController: CropViewController?
func crop() {
cropViewController?.crop()
}
func reset() {
cropViewController?.didSelectReset()
}
func rotate() {
cropViewController?.didSelectClockwiseRotate()
}
}
let initialImage: UIImage
let cropShape: Mantis.CropShapeType
let presetRatio: Mantis.PresetFixedRatioType
let proxy: Proxy
let onImageCropped: (UIImage) -> Void
func makeUIViewController(context: Context) -> some UIViewController {
var config = Mantis.Config()
config.cropViewConfig.backgroundColor = .black.withAlphaComponent(0.9)
config.cropViewConfig.cropShapeType = cropShape
config.presetFixedRatioType = presetRatio
config.showAttachedCropToolbar = false
let cropViewController = Mantis.cropViewController(
image: initialImage,
config: config
)
cropViewController.delegate = context.coordinator
context.coordinator.onImageCropped = onImageCropped
proxy.cropViewController = cropViewController
return cropViewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: CropViewControllerDelegate {
var onImageCropped: ((UIImage) -> Void)?
func cropViewControllerDidCrop(
_ cropViewController: CropViewController,
cropped: UIImage,
transformation: Transformation,
cropInfo: CropInfo
) {
onImageCropped?(cropped)
}
func cropViewControllerDidCancel(
_ cropViewController: CropViewController,
original: UIImage
) {}
func cropViewControllerDidFailToCrop(
_ cropViewController: CropViewController,
original: UIImage
) {}
func cropViewControllerDidBeginResize(
_ cropViewController: CropViewController
) {}
func cropViewControllerDidEndResize(
_ cropViewController: Mantis.CropViewController,
original: UIImage,
cropInfo: Mantis.CropInfo
) {}
}
}

View File

@ -0,0 +1,86 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import PhotosUI
import SwiftUI
// TODO: polish: find way to deselect image on appear
// - from popping from cropping
// TODO: polish: when image is picked, instead of loading it here
// which takes ~1-2s, show some kind of loading indicator
// on this view or push to another view that will go to crop
struct PhotoPickerView: UIViewControllerRepresentable {
// MARK: - Photo Picker Actions
var onSelect: (UIImage) -> Void
var onCancel: () -> Void
// MARK: - Initializer
init(onSelect: @escaping (UIImage) -> Void, onCancel: @escaping () -> Void) {
self.onSelect = onSelect
self.onCancel = onCancel
}
// MARK: - UIView Controller
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .all(of: [.images, .not(.livePhotos)])
configuration.preferredAssetRepresentationMode = .current
configuration.selection = .default
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
context.coordinator.onSelect = onSelect
context.coordinator.onCancel = onCancel
return picker
}
// MARK: - Update UIView Controller
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
// MARK: - Make Coordinator
func makeCoordinator() -> Coordinator {
Coordinator()
}
// MARK: - Coordinator
class Coordinator: PHPickerViewControllerDelegate {
var onSelect: ((UIImage) -> Void)?
var onCancel: (() -> Void)?
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let image = results.first else {
onCancel?()
return
}
let itemProvider = image.itemProvider
guard itemProvider.canLoadObject(ofClass: UIImage.self) else { return }
itemProvider.loadObject(ofClass: UIImage.self) { image, _ in
guard let image = image as? UIImage else { return }
self.onSelect?(image)
}
}
}
}

View File

@ -1,90 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import PhotosUI
import SwiftUI
// TODO: polish: find way to deselect image on appear
// - from popping from cropping
// TODO: polish: when image is picked, instead of loading it here
// which takes ~1-2s, show some kind of loading indicator
// on this view or push to another view that will go to crop
extension UserProfileImagePicker {
struct PhotoPicker: UIViewControllerRepresentable {
// MARK: - Photo Picker Actions
var onCancel: () -> Void
var onSelectedImage: (UIImage) -> Void
// MARK: - Initializer
init(onCancel: @escaping () -> Void, onSelectedImage: @escaping (UIImage) -> Void) {
self.onCancel = onCancel
self.onSelectedImage = onSelectedImage
}
// MARK: - UIView Controller
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .all(of: [.images, .not(.livePhotos)])
configuration.preferredAssetRepresentationMode = .current
configuration.selection = .ordered
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
context.coordinator.onCancel = onCancel
context.coordinator.onSelectedImage = onSelectedImage
return picker
}
// MARK: - Update UIView Controller
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
// MARK: - Make Coordinator
func makeCoordinator() -> Coordinator {
Coordinator()
}
// MARK: - Coordinator
class Coordinator: PHPickerViewControllerDelegate {
var onCancel: (() -> Void)?
var onSelectedImage: ((UIImage) -> Void)?
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let image = results.first else {
onCancel?()
return
}
let itemProvider = image.itemProvider
if itemProvider.canLoadObject(ofClass: UIImage.self) {
itemProvider.loadObject(ofClass: UIImage.self) { image, _ in
if let image = image as? UIImage {
self.onSelectedImage?(image)
}
}
}
}
}
}
}

View File

@ -1,198 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import Mantis
import SwiftUI
extension UserProfileImagePicker {
struct SquareImageCropView: View {
// MARK: - Defaults
@Default(.accentColor)
private var accentColor
// MARK: - State, Observed, & Environment Objects
@EnvironmentObject
private var router: UserProfileImageCoordinator.Router
@StateObject
private var proxy: _SquareImageCropView.Proxy = .init()
@ObservedObject
var viewModel: UserProfileImageViewModel
// MARK: - Image Variable
let image: UIImage
// MARK: - Error State
@State
private var error: Error? = nil
// MARK: - Body
var body: some View {
_SquareImageCropView(initialImage: image, proxy: proxy) {
viewModel.send(.upload($0))
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.interactiveDismissDisabled(viewModel.state == .uploading)
.navigationBarBackButtonHidden(viewModel.state == .uploading)
.topBarTrailing {
if viewModel.state == .initial {
Button(L10n.rotate, systemImage: "rotate.right") {
proxy.rotate()
}
.foregroundStyle(.gray)
}
if viewModel.state == .uploading {
Button(L10n.cancel) {
viewModel.send(.cancel)
}
.foregroundStyle(.red)
} else {
Button {
proxy.crop()
} label: {
Text(L10n.save)
.foregroundStyle(accentColor.overlayColor)
.font(.headline)
.padding(.vertical, 5)
.padding(.horizontal, 10)
.background {
accentColor
}
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
}
.toolbar {
ToolbarItem(placement: .principal) {
if viewModel.state == .uploading {
ProgressView()
} else {
Button(L10n.reset) {
proxy.reset()
}
.foregroundStyle(.yellow)
.disabled(viewModel.state == .uploading)
}
}
}
.ignoresSafeArea()
.background {
Color.black
}
.onReceive(viewModel.events) { event in
switch event {
case let .error(eventError):
error = eventError
case .deleted:
break
case .uploaded:
router.dismissCoordinator()
}
}
.errorMessage($error)
}
}
// MARK: - Square Image Crop View
struct _SquareImageCropView: UIViewControllerRepresentable {
class Proxy: ObservableObject {
weak var cropViewController: CropViewController?
func crop() {
cropViewController?.crop()
}
func reset() {
cropViewController?.didSelectReset()
}
func rotate() {
cropViewController?.didSelectClockwiseRotate()
}
}
let initialImage: UIImage
let proxy: Proxy
let onImageCropped: (UIImage) -> Void
func makeUIViewController(context: Context) -> some UIViewController {
var config = Mantis.Config()
config.cropViewConfig.backgroundColor = .black.withAlphaComponent(0.9)
config.cropViewConfig.cropShapeType = .square
config.presetFixedRatioType = .alwaysUsingOnePresetFixedRatio(ratio: 1)
config.showAttachedCropToolbar = false
let cropViewController = Mantis.cropViewController(
image: initialImage,
config: config
)
cropViewController.delegate = context.coordinator
context.coordinator.onImageCropped = onImageCropped
proxy.cropViewController = cropViewController
return cropViewController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: CropViewControllerDelegate {
var onImageCropped: ((UIImage) -> Void)?
func cropViewControllerDidCrop(
_ cropViewController: CropViewController,
cropped: UIImage,
transformation: Transformation,
cropInfo: CropInfo
) {
onImageCropped?(cropped)
}
func cropViewControllerDidCancel(
_ cropViewController: CropViewController,
original: UIImage
) {}
func cropViewControllerDidFailToCrop(
_ cropViewController: CropViewController,
original: UIImage
) {}
func cropViewControllerDidBeginResize(
_ cropViewController: CropViewController
) {}
func cropViewControllerDidEndResize(
_ cropViewController: Mantis.CropViewController,
original: UIImage,
cropInfo: Mantis.CropInfo
) {}
}
}
}

View File

@ -0,0 +1,61 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import JellyfinAPI
import Mantis
import SwiftUI
struct UserProfileImageCropView: View {
// MARK: - State, Observed, & Environment Objects
@EnvironmentObject
private var router: UserProfileImageCoordinator.Router
@ObservedObject
var viewModel: UserProfileImageViewModel
// MARK: - Image Variable
let image: UIImage
// MARK: - Error State
@State
private var error: Error?
// MARK: - Body
var body: some View {
PhotoCropView(
isSaving: viewModel.state == .uploading,
image: image,
cropShape: .square,
presetRatio: .alwaysUsingOnePresetFixedRatio(ratio: 1)
) {
viewModel.send(.upload($0))
} onCancel: {
router.dismissCoordinator()
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.interactiveDismissDisabled(viewModel.state == .uploading)
.navigationBarBackButtonHidden(viewModel.state == .uploading)
.onReceive(viewModel.events) { event in
switch event {
case let .error(eventError):
error = eventError
case .deleted:
break
case .uploaded:
router.dismissCoordinator()
}
}
.errorMessage($error)
}
}

View File

@ -8,23 +8,20 @@
import SwiftUI import SwiftUI
struct UserProfileImagePicker: View { struct UserProfileImagePickerView: View {
// MARK: - Observed, & Environment Objects // MARK: - Observed, & Environment Objects
@EnvironmentObject @EnvironmentObject
private var router: UserProfileImageCoordinator.Router private var router: UserProfileImageCoordinator.Router
@ObservedObject
var viewModel: UserProfileImageViewModel
// MARK: - Body // MARK: - Body
var body: some View { var body: some View {
PhotoPicker { PhotoPickerView {
router.route(to: \.cropImage, $0)
} onCancel: {
router.dismissCoordinator() router.dismissCoordinator()
} onSelectedImage: { image in
router.route(to: \.cropImage, image)
} }
} }
} }

View File

@ -94,12 +94,18 @@
/// Album Artist /// Album Artist
"albumArtist" = "Album Artist"; "albumArtist" = "Album Artist";
/// All
"all" = "All";
/// All Audiences /// All Audiences
"allAudiences" = "All Audiences"; "allAudiences" = "All Audiences";
/// View all past and present devices that have connected. /// View all past and present devices that have connected.
"allDevicesDescription" = "View all past and present devices that have connected."; "allDevicesDescription" = "View all past and present devices that have connected.";
/// All languages
"allLanguages" = "All languages";
/// All Media /// All Media
"allMedia" = "All Media"; "allMedia" = "All Media";
@ -157,6 +163,9 @@
/// Arranger /// Arranger
"arranger" = "Arranger"; "arranger" = "Arranger";
/// Art
"art" = "Art";
/// Artist /// Artist
"artist" = "Artist"; "artist" = "Artist";
@ -211,6 +220,12 @@
/// Back /// Back
"back" = "Back"; "back" = "Back";
/// Backdrop
"backdrop" = "Backdrop";
/// Banner
"banner" = "Banner";
/// Bar Buttons /// Bar Buttons
"barButtons" = "Bar Buttons"; "barButtons" = "Bar Buttons";
@ -307,6 +322,12 @@
/// Books /// Books
"books" = "Books"; "books" = "Books";
/// Box
"box" = "Box";
/// BoxRear
"boxRear" = "BoxRear";
/// Bugs and Features /// Bugs and Features
"bugsAndFeatures" = "Bugs and Features"; "bugsAndFeatures" = "Bugs and Features";
@ -337,6 +358,9 @@
/// Channels /// Channels
"channels" = "Channels"; "channels" = "Channels";
/// Chapter
"chapter" = "Chapter";
/// Chapters /// Chapters
"chapters" = "Chapters"; "chapters" = "Chapters";
@ -562,6 +586,9 @@
/// Are you sure you wish to delete this device? This session will be logged out. /// Are you sure you wish to delete this device? This session will be logged out.
"deleteDeviceWarning" = "Are you sure you wish to delete this device? This session will be logged out."; "deleteDeviceWarning" = "Are you sure you wish to delete this device? This session will be logged out.";
/// Delete image
"deleteImage" = "Delete image";
/// Are you sure you want to delete this item? /// Are you sure you want to delete this item?
"deleteItemConfirmation" = "Are you sure you want to delete this item?"; "deleteItemConfirmation" = "Are you sure you want to delete this item?";
@ -652,6 +679,9 @@
/// Digital /// Digital
"digital" = "Digital"; "digital" = "Digital";
/// Dimensions
"dimensions" = "Dimensions";
/// Direct Play /// Direct Play
"direct" = "Direct Play"; "direct" = "Direct Play";
@ -673,6 +703,9 @@
/// Disabled /// Disabled
"disabled" = "Disabled"; "disabled" = "Disabled";
/// Disc
"disc" = "Disc";
/// Disclaimer /// Disclaimer
"disclaimer" = "Disclaimer"; "disclaimer" = "Disclaimer";
@ -880,6 +913,18 @@
/// Illustrator /// Illustrator
"illustrator" = "Illustrator"; "illustrator" = "Illustrator";
/// Images
"image" = "Images";
/// Images
"images" = "Images";
/// Image source
"imageSource" = "Image source";
/// Index
"index" = "Index";
/// Indicators /// Indicators
"indicators" = "Indicators"; "indicators" = "Indicators";
@ -979,6 +1024,9 @@
/// Liked Items /// Liked Items
"likedItems" = "Liked Items"; "likedItems" = "Liked Items";
/// Likes
"likes" = "Likes";
/// List /// List
"list" = "List"; "list" = "List";
@ -1012,6 +1060,9 @@
/// Locked users /// Locked users
"lockedUsers" = "Locked users"; "lockedUsers" = "Locked users";
/// Logo
"logo" = "Logo";
/// Logs /// Logs
"logs" = "Logs"; "logs" = "Logs";
@ -1072,6 +1123,9 @@
/// Mbps /// Mbps
"megabitsPerSecond" = "Mbps"; "megabitsPerSecond" = "Mbps";
/// Menu
"menu" = "Menu";
/// Menu Buttons /// Menu Buttons
"menuButtons" = "Menu Buttons"; "menuButtons" = "Menu Buttons";
@ -1321,6 +1375,9 @@
/// Production Year /// Production Year
"productionYear" = "Production Year"; "productionYear" = "Production Year";
/// Profile
"profile" = "Profile";
/// Profile Image /// Profile Image
"profileImage" = "Profile Image"; "profileImage" = "Profile Image";
@ -1525,6 +1582,12 @@
/// Schedule already exists /// Schedule already exists
"scheduleAlreadyExists" = "Schedule already exists"; "scheduleAlreadyExists" = "Schedule already exists";
/// Score
"score" = "Score";
/// Screenshot
"screenshot" = "Screenshot";
/// Scrub Current Time /// Scrub Current Time
"scrubCurrentTime" = "Scrub Current Time"; "scrubCurrentTime" = "Scrub Current Time";
@ -1789,6 +1852,9 @@
/// Test Size /// Test Size
"testSize" = "Test Size"; "testSize" = "Test Size";
/// Thumb
"thumb" = "Thumb";
/// Time /// Time
"time" = "Time"; "time" = "Time";
@ -1891,6 +1957,12 @@
/// You have unsaved changes. Are you sure you want to discard them? /// You have unsaved changes. Are you sure you want to discard them?
"unsavedChangesMessage" = "You have unsaved changes. Are you sure you want to discard them?"; "unsavedChangesMessage" = "You have unsaved changes. Are you sure you want to discard them?";
/// Upload file
"uploadFile" = "Upload file";
/// Upload photo
"uploadPhoto" = "Upload photo";
/// URL /// URL
"url" = "URL"; "url" = "URL";
@ -1969,6 +2041,9 @@
/// Some views may need an app restart to update. /// Some views may need an app restart to update.
"viewsMayRequireRestart" = "Some views may need an app restart to update."; "viewsMayRequireRestart" = "Some views may need an app restart to update.";
/// Votes
"votes" = "Votes";
/// Weekday /// Weekday
"weekday" = "Weekday"; "weekday" = "Weekday";