[iOS] Media Item Menu - Edit Arrays (People, Genres, Studios, & Tags) (#1336)
* Cleanup / Genre & Tag Management * Move searching to a backgroundState. Fix the font Color when bulk editing tags / genres should be secondary when editing & not selected * Cleanup * Now that cancelling is handled better this should prevent the issue where the suggestions fails to update on a letter entry * Change from using an event for searchResults to using a published searchResults var * Moved all logic to a local list where all genres/tags are populated on refresh then filterd locally instead of calling the server for changes. * Inheritance * Split metadata from components then alphabetize. Also, fix but where you can't add a people * People & Permissions * Functional but dirty. TODO: Cleanup + Trie? Trei? * nil coalescing operator is only evaluated if the lhs is nil, coalescing operator with nil as rhs is redundant * TODO: Search improvements & Delay search on name change * Cleanup & reordering * Debouncing * Trie implementation * Permissions Cleanup Squeezing in: https://github.com/jellyfin/jellyfin-web/issues/6361 * enhance Trie * cleanup * cleanup --------- Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
parent
95c4395c11
commit
a3d84a958f
|
@ -19,19 +19,131 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {
|
|||
|
||||
private let viewModel: ItemViewModel
|
||||
|
||||
// MARK: - Route to Metadata
|
||||
|
||||
@Route(.modal)
|
||||
var editMetadata = makeEditMetadata
|
||||
|
||||
// MARK: - Route to Genres
|
||||
|
||||
@Route(.push)
|
||||
var editGenres = makeEditGenres
|
||||
@Route(.modal)
|
||||
var addGenre = makeAddGenre
|
||||
|
||||
// MARK: - Route to Tags
|
||||
|
||||
@Route(.push)
|
||||
var editTags = makeEditTags
|
||||
@Route(.modal)
|
||||
var addTag = makeAddTag
|
||||
|
||||
// MARK: - Route to Studios
|
||||
|
||||
@Route(.push)
|
||||
var editStudios = makeEditStudios
|
||||
@Route(.modal)
|
||||
var addStudio = makeAddStudio
|
||||
|
||||
// MARK: - Route to People
|
||||
|
||||
@Route(.push)
|
||||
var editPeople = makeEditPeople
|
||||
@Route(.modal)
|
||||
var addPeople = makeAddPeople
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(viewModel: ItemViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
// MARK: - Item Metadata
|
||||
|
||||
func makeEditMetadata(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
EditMetadataView(viewModel: ItemEditorViewModel(item: item))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item Genres
|
||||
|
||||
@ViewBuilder
|
||||
func makeEditGenres(item: BaseItemDto) -> some View {
|
||||
EditItemElementView<String>(
|
||||
viewModel: GenreEditorViewModel(item: item),
|
||||
type: .genres,
|
||||
route: { router, viewModel in
|
||||
router.route(to: \.addGenre, viewModel as! GenreEditorViewModel)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func makeAddGenre(viewModel: GenreEditorViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
AddItemElementView(viewModel: viewModel, type: .genres)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item Tags
|
||||
|
||||
@ViewBuilder
|
||||
func makeEditTags(item: BaseItemDto) -> some View {
|
||||
EditItemElementView<String>(
|
||||
viewModel: TagEditorViewModel(item: item),
|
||||
type: .tags,
|
||||
route: { router, viewModel in
|
||||
router.route(to: \.addTag, viewModel as! TagEditorViewModel)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func makeAddTag(viewModel: TagEditorViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
AddItemElementView(viewModel: viewModel, type: .tags)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item Studios
|
||||
|
||||
@ViewBuilder
|
||||
func makeEditStudios(item: BaseItemDto) -> some View {
|
||||
EditItemElementView<NameGuidPair>(
|
||||
viewModel: StudioEditorViewModel(item: item),
|
||||
type: .studios,
|
||||
route: { router, viewModel in
|
||||
router.route(to: \.addStudio, viewModel as! StudioEditorViewModel)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func makeAddStudio(viewModel: StudioEditorViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
AddItemElementView(viewModel: viewModel, type: .studios)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item People
|
||||
|
||||
@ViewBuilder
|
||||
func makeEditPeople(item: BaseItemDto) -> some View {
|
||||
EditItemElementView<BaseItemPerson>(
|
||||
viewModel: PeopleEditorViewModel(item: item),
|
||||
type: .people,
|
||||
route: { router, viewModel in
|
||||
router.route(to: \.addPeople, viewModel as! PeopleEditorViewModel)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func makeAddPeople(viewModel: PeopleEditorViewModel) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
|
||||
NavigationViewCoordinator {
|
||||
AddItemElementView(viewModel: viewModel, type: .people)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Start
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
ItemEditorView(viewModel: viewModel)
|
||||
|
|
|
@ -21,4 +21,8 @@ extension Collection {
|
|||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
|
||||
func keyed<Key>(using: KeyPath<Element, Key>) -> [Key: Element] {
|
||||
Dictionary(uniqueKeysWithValues: map { ($0[keyPath: using], $0) })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
// TODO: No longer needed in 10.9+
|
||||
public enum PersonKind: String, Codable, CaseIterable {
|
||||
case unknown = "Unknown"
|
||||
case actor = "Actor"
|
||||
case director = "Director"
|
||||
case composer = "Composer"
|
||||
case writer = "Writer"
|
||||
case guestStar = "GuestStar"
|
||||
case producer = "Producer"
|
||||
case conductor = "Conductor"
|
||||
case lyricist = "Lyricist"
|
||||
case arranger = "Arranger"
|
||||
case engineer = "Engineer"
|
||||
case mixer = "Mixer"
|
||||
case remixer = "Remixer"
|
||||
case creator = "Creator"
|
||||
case artist = "Artist"
|
||||
case albumArtist = "AlbumArtist"
|
||||
case author = "Author"
|
||||
case illustrator = "Illustrator"
|
||||
case penciller = "Penciller"
|
||||
case inker = "Inker"
|
||||
case colorist = "Colorist"
|
||||
case letterer = "Letterer"
|
||||
case coverArtist = "CoverArtist"
|
||||
case editor = "Editor"
|
||||
case translator = "Translator"
|
||||
}
|
||||
|
||||
// TODO: Still needed in 10.9+
|
||||
extension PersonKind: Displayable {
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .unknown:
|
||||
return L10n.unknown
|
||||
case .actor:
|
||||
return L10n.actor
|
||||
case .director:
|
||||
return L10n.director
|
||||
case .composer:
|
||||
return L10n.composer
|
||||
case .writer:
|
||||
return L10n.writer
|
||||
case .guestStar:
|
||||
return L10n.guestStar
|
||||
case .producer:
|
||||
return L10n.producer
|
||||
case .conductor:
|
||||
return L10n.conductor
|
||||
case .lyricist:
|
||||
return L10n.lyricist
|
||||
case .arranger:
|
||||
return L10n.arranger
|
||||
case .engineer:
|
||||
return L10n.engineer
|
||||
case .mixer:
|
||||
return L10n.mixer
|
||||
case .remixer:
|
||||
return L10n.remixer
|
||||
case .creator:
|
||||
return L10n.creator
|
||||
case .artist:
|
||||
return L10n.artist
|
||||
case .albumArtist:
|
||||
return L10n.albumArtist
|
||||
case .author:
|
||||
return L10n.author
|
||||
case .illustrator:
|
||||
return L10n.illustrator
|
||||
case .penciller:
|
||||
return L10n.penciller
|
||||
case .inker:
|
||||
return L10n.inker
|
||||
case .colorist:
|
||||
return L10n.colorist
|
||||
case .letterer:
|
||||
return L10n.letterer
|
||||
case .coverArtist:
|
||||
return L10n.coverArtist
|
||||
case .editor:
|
||||
return L10n.editor
|
||||
case .translator:
|
||||
return L10n.translator
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
enum ItemArrayElements: Displayable {
|
||||
case studios
|
||||
case genres
|
||||
case tags
|
||||
case people
|
||||
|
||||
// MARK: - Localized Title
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .studios:
|
||||
return L10n.studios
|
||||
case .genres:
|
||||
return L10n.genres
|
||||
case .tags:
|
||||
return L10n.tags
|
||||
case .people:
|
||||
return L10n.people
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Localized Description
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .studios:
|
||||
return L10n.studiosDescription
|
||||
case .genres:
|
||||
return L10n.genresDescription
|
||||
case .tags:
|
||||
return L10n.tagsDescription
|
||||
case .people:
|
||||
return L10n.peopleDescription
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create Element from Components
|
||||
|
||||
func createElement<T: Hashable>(
|
||||
name: String,
|
||||
id: String?,
|
||||
personRole: String?,
|
||||
personKind: String?
|
||||
) -> T {
|
||||
switch self {
|
||||
case .genres, .tags:
|
||||
return name as! T
|
||||
case .studios:
|
||||
return NameGuidPair(id: id, name: name) as! T
|
||||
case .people:
|
||||
return BaseItemPerson(
|
||||
id: id,
|
||||
name: name,
|
||||
role: personRole,
|
||||
type: personKind
|
||||
) as! T
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Get the Element from the BaseItemDto Based on Type
|
||||
|
||||
func getElement<T: Hashable>(for item: BaseItemDto) -> [T] {
|
||||
switch self {
|
||||
case .studios:
|
||||
return item.studios as? [T] ?? []
|
||||
case .genres:
|
||||
return item.genres as? [T] ?? []
|
||||
case .tags:
|
||||
return item.tags as? [T] ?? []
|
||||
case .people:
|
||||
return item.people as? [T] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Get the Name from the Element Based on Type
|
||||
|
||||
func getId(for element: AnyHashable) -> String? {
|
||||
switch self {
|
||||
case .genres, .tags:
|
||||
return nil
|
||||
case .studios:
|
||||
return (element.base as? NameGuidPair)?.id
|
||||
case .people:
|
||||
return (element.base as? BaseItemPerson)?.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Get the Id from the Element Based on Type
|
||||
|
||||
func getName(for element: AnyHashable) -> String {
|
||||
switch self {
|
||||
case .genres, .tags:
|
||||
return element.base as? String ?? L10n.unknown
|
||||
case .studios:
|
||||
return (element.base as? NameGuidPair)?.name ?? L10n.unknown
|
||||
case .people:
|
||||
return (element.base as? BaseItemPerson)?.name ?? L10n.unknown
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,9 +19,9 @@ enum LibraryDisplayType: String, CaseIterable, Displayable, Storable, SystemImag
|
|||
var displayTitle: String {
|
||||
switch self {
|
||||
case .grid:
|
||||
"Grid"
|
||||
L10n.grid
|
||||
case .list:
|
||||
"List"
|
||||
L10n.list
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
class Trie<Key: Collection & Hashable, Element> where Key.Element: Hashable {
|
||||
|
||||
class TrieNode {
|
||||
var children: [Key.Element: TrieNode] = [:]
|
||||
var isLeafNode: Bool = false
|
||||
var elements: [Element] = []
|
||||
}
|
||||
|
||||
private let root = TrieNode()
|
||||
}
|
||||
|
||||
extension Trie {
|
||||
|
||||
func contains(key: Key) -> Bool {
|
||||
var currentNode = root
|
||||
|
||||
for key in key {
|
||||
guard let nextNode = currentNode.children[key] else {
|
||||
return false
|
||||
}
|
||||
currentNode = nextNode
|
||||
}
|
||||
|
||||
return currentNode.isLeafNode
|
||||
}
|
||||
|
||||
func insert(key: Key, element: Element) {
|
||||
var currentNode = root
|
||||
|
||||
for key in key {
|
||||
if currentNode.children[key] == nil {
|
||||
currentNode.children[key] = TrieNode()
|
||||
}
|
||||
currentNode = currentNode.children[key]!
|
||||
currentNode.elements.append(element)
|
||||
}
|
||||
currentNode.isLeafNode = true
|
||||
}
|
||||
|
||||
func insert(contentsOf contents: [Key: Element]) {
|
||||
for (key, element) in contents {
|
||||
insert(key: key, element: element)
|
||||
}
|
||||
}
|
||||
|
||||
func search(prefix: Key) -> [Element] {
|
||||
|
||||
guard prefix.isNotEmpty else { return [] }
|
||||
|
||||
var currentNode = root
|
||||
|
||||
for key in prefix {
|
||||
guard let nextNode = currentNode.children[key] else {
|
||||
return []
|
||||
}
|
||||
currentNode = nextNode
|
||||
}
|
||||
|
||||
return currentNode.elements
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
|
||||
struct UserPermissions {
|
||||
|
||||
let isAdministrator: Bool
|
||||
let items: UserItemPermissions
|
||||
|
||||
init(_ policy: UserPolicy?) {
|
||||
self.isAdministrator = policy?.isAdministrator ?? false
|
||||
self.items = UserItemPermissions(policy, isAdministrator: isAdministrator)
|
||||
}
|
||||
|
||||
struct UserItemPermissions {
|
||||
|
||||
let canDelete: Bool
|
||||
let canDownload: Bool
|
||||
let canEditMetadata: Bool
|
||||
let canManageSubtitles: Bool
|
||||
let canManageCollections: Bool
|
||||
let canManageLyrics: Bool
|
||||
|
||||
init(_ policy: UserPolicy?, isAdministrator: Bool) {
|
||||
self.canDelete = policy?.enableContentDeletion ?? false || policy?.enableContentDeletionFromFolders != []
|
||||
self.canDownload = policy?.enableContentDownloading ?? false
|
||||
self.canEditMetadata = isAdministrator
|
||||
// TODO: SDK 10.9 Enable Comments
|
||||
self.canManageSubtitles = isAdministrator // || policy?.enableSubtitleManagement ?? false
|
||||
self.canManageCollections = isAdministrator // || policy?.enableCollectionManagement ?? false
|
||||
// TODO: SDK 10.10 Enable Comments
|
||||
self.canManageLyrics = isAdministrator // || policy?.enableSubtitleManagement ?? false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -28,6 +28,8 @@ internal enum L10n {
|
|||
internal static let activeDevices = L10n.tr("Localizable", "activeDevices", fallback: "Active Devices")
|
||||
/// Activity
|
||||
internal static let activity = L10n.tr("Localizable", "activity", fallback: "Activity")
|
||||
/// Actor
|
||||
internal static let actor = L10n.tr("Localizable", "actor", fallback: "Actor")
|
||||
/// Add
|
||||
internal static let add = L10n.tr("Localizable", "add", fallback: "Add")
|
||||
/// Add API key
|
||||
|
@ -54,12 +56,16 @@ internal enum L10n {
|
|||
internal static func airWithDate(_ p1: UnsafePointer<CChar>) -> String {
|
||||
return L10n.tr("Localizable", "airWithDate", p1, fallback: "Airs %s")
|
||||
}
|
||||
/// Album Artist
|
||||
internal static let albumArtist = L10n.tr("Localizable", "albumArtist", fallback: "Album Artist")
|
||||
/// 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 Genres
|
||||
internal static let allGenres = L10n.tr("Localizable", "allGenres", fallback: "All Genres")
|
||||
/// All Media
|
||||
internal static let allMedia = L10n.tr("Localizable", "allMedia", fallback: "All Media")
|
||||
/// Allow collection management
|
||||
internal static let allowCollectionManagement = L10n.tr("Localizable", "allowCollectionManagement", fallback: "Allow collection management")
|
||||
/// Allow media item deletion
|
||||
internal static let allowItemDeletion = L10n.tr("Localizable", "allowItemDeletion", fallback: "Allow media item deletion")
|
||||
/// Allow media item editing
|
||||
|
@ -92,6 +98,10 @@ internal enum L10n {
|
|||
internal static let applicationName = L10n.tr("Localizable", "applicationName", fallback: "Application Name")
|
||||
/// Apply
|
||||
internal static let apply = L10n.tr("Localizable", "apply", fallback: "Apply")
|
||||
/// Arranger
|
||||
internal static let arranger = L10n.tr("Localizable", "arranger", fallback: "Arranger")
|
||||
/// Artist
|
||||
internal static let artist = L10n.tr("Localizable", "artist", fallback: "Artist")
|
||||
/// Aspect Fill
|
||||
internal static let aspectFill = L10n.tr("Localizable", "aspectFill", fallback: "Aspect Fill")
|
||||
/// Audio
|
||||
|
@ -118,6 +128,8 @@ internal enum L10n {
|
|||
internal static let audioTrack = L10n.tr("Localizable", "audioTrack", fallback: "Audio Track")
|
||||
/// Audio transcoding
|
||||
internal static let audioTranscoding = L10n.tr("Localizable", "audioTranscoding", fallback: "Audio transcoding")
|
||||
/// Author
|
||||
internal static let author = L10n.tr("Localizable", "author", fallback: "Author")
|
||||
/// Authorize
|
||||
internal static let authorize = L10n.tr("Localizable", "authorize", fallback: "Authorize")
|
||||
/// Auto
|
||||
|
@ -232,6 +244,8 @@ internal enum L10n {
|
|||
internal static let collections = L10n.tr("Localizable", "collections", fallback: "Collections")
|
||||
/// Color
|
||||
internal static let color = L10n.tr("Localizable", "color", fallback: "Color")
|
||||
/// Colorist
|
||||
internal static let colorist = L10n.tr("Localizable", "colorist", fallback: "Colorist")
|
||||
/// Columns
|
||||
internal static let columns = L10n.tr("Localizable", "columns", fallback: "Columns")
|
||||
/// Coming soon
|
||||
|
@ -250,6 +264,10 @@ internal enum L10n {
|
|||
internal static let compatible = L10n.tr("Localizable", "compatible", fallback: "Most Compatible")
|
||||
/// Converts all media to H.264 video and AAC audio for maximum compatibility. May require server transcoding for non-compatible media types.
|
||||
internal static let compatibleDescription = L10n.tr("Localizable", "compatibleDescription", fallback: "Converts all media to H.264 video and AAC audio for maximum compatibility. May require server transcoding for non-compatible media types.")
|
||||
/// Composer
|
||||
internal static let composer = L10n.tr("Localizable", "composer", fallback: "Composer")
|
||||
/// Conductor
|
||||
internal static let conductor = L10n.tr("Localizable", "conductor", fallback: "Conductor")
|
||||
/// Confirm
|
||||
internal static let confirm = L10n.tr("Localizable", "confirm", fallback: "Confirm")
|
||||
/// Confirm Close
|
||||
|
@ -288,12 +306,16 @@ internal enum L10n {
|
|||
internal static let controlSharedDevices = L10n.tr("Localizable", "controlSharedDevices", fallback: "Control shared devices")
|
||||
/// Country
|
||||
internal static let country = L10n.tr("Localizable", "country", fallback: "Country")
|
||||
/// Cover Artist
|
||||
internal static let coverArtist = L10n.tr("Localizable", "coverArtist", fallback: "Cover Artist")
|
||||
/// Create & Join Groups
|
||||
internal static let createAndJoinGroups = L10n.tr("Localizable", "createAndJoinGroups", fallback: "Create & Join Groups")
|
||||
/// Create API Key
|
||||
internal static let createAPIKey = L10n.tr("Localizable", "createAPIKey", fallback: "Create API Key")
|
||||
/// Enter the application name for the new API key.
|
||||
internal static let createAPIKeyMessage = L10n.tr("Localizable", "createAPIKeyMessage", fallback: "Enter the application name for the new API key.")
|
||||
/// Creator
|
||||
internal static let creator = L10n.tr("Localizable", "creator", fallback: "Creator")
|
||||
/// Critics
|
||||
internal static let critics = L10n.tr("Localizable", "critics", fallback: "Critics")
|
||||
/// Current
|
||||
|
@ -376,8 +398,12 @@ internal enum L10n {
|
|||
}
|
||||
/// 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.")
|
||||
/// 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.
|
||||
internal static let deleteItemConfirmationMessage = L10n.tr("Localizable", "deleteItemConfirmationMessage", fallback: "Are you sure you want to delete this item? This action cannot be undone.")
|
||||
/// Are you sure you want to delete the selected items?
|
||||
internal static let deleteSelectedConfirmation = L10n.tr("Localizable", "deleteSelectedConfirmation", fallback: "Are you sure you want to delete the selected items?")
|
||||
/// Delete Selected Devices
|
||||
internal static let deleteSelectedDevices = L10n.tr("Localizable", "deleteSelectedDevices", fallback: "Delete Selected Devices")
|
||||
/// Delete Selected Users
|
||||
|
@ -422,8 +448,8 @@ internal enum L10n {
|
|||
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.
|
||||
internal static let directDescription = L10n.tr("Localizable", "directDescription", fallback: "Plays content in its original format. May cause playback issues on unsupported media types.")
|
||||
/// DIRECTOR
|
||||
internal static let director = L10n.tr("Localizable", "director", fallback: "DIRECTOR")
|
||||
/// Director
|
||||
internal static let director = L10n.tr("Localizable", "director", fallback: "Director")
|
||||
/// Direct Play
|
||||
internal static let directPlay = L10n.tr("Localizable", "directPlay", fallback: "Direct Play")
|
||||
/// An error occurred during direct play
|
||||
|
@ -450,6 +476,8 @@ internal enum L10n {
|
|||
internal static let edit = L10n.tr("Localizable", "edit", fallback: "Edit")
|
||||
/// Edit Jump Lengths
|
||||
internal static let editJumpLengths = L10n.tr("Localizable", "editJumpLengths", fallback: "Edit Jump Lengths")
|
||||
/// Editor
|
||||
internal static let editor = L10n.tr("Localizable", "editor", fallback: "Editor")
|
||||
/// Edit Server
|
||||
internal static let editServer = L10n.tr("Localizable", "editServer", fallback: "Edit Server")
|
||||
/// Edit Users
|
||||
|
@ -464,6 +492,8 @@ internal enum L10n {
|
|||
internal static let endDate = L10n.tr("Localizable", "endDate", fallback: "End Date")
|
||||
/// Ended
|
||||
internal static let ended = L10n.tr("Localizable", "ended", fallback: "Ended")
|
||||
/// Engineer
|
||||
internal static let engineer = L10n.tr("Localizable", "engineer", fallback: "Engineer")
|
||||
/// Enter custom bitrate in Mbps
|
||||
internal static let enterCustomBitrate = L10n.tr("Localizable", "enterCustomBitrate", fallback: "Enter custom bitrate in Mbps")
|
||||
/// Enter custom failed logins limit
|
||||
|
@ -498,10 +528,14 @@ internal enum L10n {
|
|||
}
|
||||
/// Executed
|
||||
internal static let executed = L10n.tr("Localizable", "executed", fallback: "Executed")
|
||||
/// Existing items
|
||||
internal static let existingItems = L10n.tr("Localizable", "existingItems", fallback: "Existing items")
|
||||
/// Existing Server
|
||||
internal static let existingServer = L10n.tr("Localizable", "existingServer", fallback: "Existing Server")
|
||||
/// Existing User
|
||||
internal static let existingUser = L10n.tr("Localizable", "existingUser", fallback: "Existing User")
|
||||
/// This item exists on your Jellyfin Server.
|
||||
internal static let existsOnServer = L10n.tr("Localizable", "existsOnServer", fallback: "This item exists on your Jellyfin Server.")
|
||||
/// Experimental
|
||||
internal static let experimental = L10n.tr("Localizable", "experimental", fallback: "Experimental")
|
||||
/// Failed logins
|
||||
|
@ -542,6 +576,8 @@ internal enum L10n {
|
|||
internal static let fullTopAndBottom = L10n.tr("Localizable", "fullTopAndBottom", fallback: "Full Top and Bottom")
|
||||
/// Genres
|
||||
internal static let genres = L10n.tr("Localizable", "genres", fallback: "Genres")
|
||||
/// Categories that describe the themes or styles of media.
|
||||
internal static let genresDescription = L10n.tr("Localizable", "genresDescription", fallback: "Categories that describe the themes or styles of media.")
|
||||
/// Gestures
|
||||
internal static let gestures = L10n.tr("Localizable", "gestures", fallback: "Gestures")
|
||||
/// Gbps
|
||||
|
@ -550,6 +586,8 @@ internal enum L10n {
|
|||
internal static let green = L10n.tr("Localizable", "green", fallback: "Green")
|
||||
/// Grid
|
||||
internal static let grid = L10n.tr("Localizable", "grid", fallback: "Grid")
|
||||
/// Guest Star
|
||||
internal static let guestStar = L10n.tr("Localizable", "guestStar", fallback: "Guest Star")
|
||||
/// Half Side-by-Side
|
||||
internal static let halfSideBySide = L10n.tr("Localizable", "halfSideBySide", fallback: "Half Side-by-Side")
|
||||
/// Half Top and Bottom
|
||||
|
@ -566,10 +604,14 @@ internal enum L10n {
|
|||
internal static let hours = L10n.tr("Localizable", "hours", fallback: "Hours")
|
||||
/// Idle
|
||||
internal static let idle = L10n.tr("Localizable", "idle", fallback: "Idle")
|
||||
/// Illustrator
|
||||
internal static let illustrator = L10n.tr("Localizable", "illustrator", fallback: "Illustrator")
|
||||
/// Indicators
|
||||
internal static let indicators = L10n.tr("Localizable", "indicators", fallback: "Indicators")
|
||||
/// Information
|
||||
internal static let information = L10n.tr("Localizable", "information", fallback: "Information")
|
||||
/// Inker
|
||||
internal static let inker = L10n.tr("Localizable", "inker", fallback: "Inker")
|
||||
/// Interlaced video is not supported
|
||||
internal static let interlacedVideoNotSupported = L10n.tr("Localizable", "interlacedVideoNotSupported", fallback: "Interlaced video is not supported")
|
||||
/// Interval
|
||||
|
@ -638,6 +680,8 @@ internal enum L10n {
|
|||
internal static let `left` = L10n.tr("Localizable", "left", fallback: "Left")
|
||||
/// Letter
|
||||
internal static let letter = L10n.tr("Localizable", "letter", fallback: "Letter")
|
||||
/// Letterer
|
||||
internal static let letterer = L10n.tr("Localizable", "letterer", fallback: "Letterer")
|
||||
/// Letter Picker
|
||||
internal static let letterPicker = L10n.tr("Localizable", "letterPicker", fallback: "Letter Picker")
|
||||
/// Library
|
||||
|
@ -674,6 +718,8 @@ internal enum L10n {
|
|||
internal static let logs = L10n.tr("Localizable", "logs", fallback: "Logs")
|
||||
/// Access the Jellyfin server logs for troubleshooting and monitoring purposes.
|
||||
internal static let logsDescription = L10n.tr("Localizable", "logsDescription", fallback: "Access the Jellyfin server logs for troubleshooting and monitoring purposes.")
|
||||
/// Lyricist
|
||||
internal static let lyricist = L10n.tr("Localizable", "lyricist", fallback: "Lyricist")
|
||||
/// Lyrics
|
||||
internal static let lyrics = L10n.tr("Localizable", "lyrics", fallback: "Lyrics")
|
||||
/// Management
|
||||
|
@ -720,6 +766,8 @@ internal enum L10n {
|
|||
internal static let missing = L10n.tr("Localizable", "missing", fallback: "Missing")
|
||||
/// Missing Items
|
||||
internal static let missingItems = L10n.tr("Localizable", "missingItems", fallback: "Missing Items")
|
||||
/// Mixer
|
||||
internal static let mixer = L10n.tr("Localizable", "mixer", fallback: "Mixer")
|
||||
/// More Like This
|
||||
internal static let moreLikeThis = L10n.tr("Localizable", "moreLikeThis", fallback: "More Like This")
|
||||
/// Movies
|
||||
|
@ -848,8 +896,12 @@ internal enum L10n {
|
|||
internal static let passwordsDoNotMatch = L10n.tr("Localizable", "passwordsDoNotMatch", fallback: "New passwords do not match.")
|
||||
/// Pause on background
|
||||
internal static let pauseOnBackground = L10n.tr("Localizable", "pauseOnBackground", fallback: "Pause on background")
|
||||
/// Penciller
|
||||
internal static let penciller = L10n.tr("Localizable", "penciller", fallback: "Penciller")
|
||||
/// People
|
||||
internal static let people = L10n.tr("Localizable", "people", fallback: "People")
|
||||
/// People who helped create or perform specific media.
|
||||
internal static let peopleDescription = L10n.tr("Localizable", "peopleDescription", fallback: "People who helped create or perform specific media.")
|
||||
/// Permissions
|
||||
internal static let permissions = L10n.tr("Localizable", "permissions", fallback: "Permissions")
|
||||
/// Play
|
||||
|
@ -892,6 +944,8 @@ internal enum L10n {
|
|||
internal static let previousItem = L10n.tr("Localizable", "previousItem", fallback: "Previous Item")
|
||||
/// Primary
|
||||
internal static let primary = L10n.tr("Localizable", "primary", fallback: "Primary")
|
||||
/// Producer
|
||||
internal static let producer = L10n.tr("Localizable", "producer", fallback: "Producer")
|
||||
/// Production
|
||||
internal static let production = L10n.tr("Localizable", "production", fallback: "Production")
|
||||
/// Production Locations
|
||||
|
@ -958,6 +1012,8 @@ internal enum L10n {
|
|||
internal static let reload = L10n.tr("Localizable", "reload", fallback: "Reload")
|
||||
/// Remaining Time
|
||||
internal static let remainingTime = L10n.tr("Localizable", "remainingTime", fallback: "Remaining Time")
|
||||
/// Remixer
|
||||
internal static let remixer = L10n.tr("Localizable", "remixer", fallback: "Remixer")
|
||||
/// Remote connections
|
||||
internal static let remoteConnections = L10n.tr("Localizable", "remoteConnections", fallback: "Remote connections")
|
||||
/// Remote control
|
||||
|
@ -974,6 +1030,8 @@ internal enum L10n {
|
|||
internal static let removeFromResume = L10n.tr("Localizable", "removeFromResume", fallback: "Remove From Resume")
|
||||
/// Remux
|
||||
internal static let remux = L10n.tr("Localizable", "remux", fallback: "Remux")
|
||||
/// Reorder
|
||||
internal static let reorder = L10n.tr("Localizable", "reorder", fallback: "Reorder")
|
||||
/// Replace All
|
||||
internal static let replaceAll = L10n.tr("Localizable", "replaceAll", fallback: "Replace All")
|
||||
/// Replace all unlocked metadata and images with new information.
|
||||
|
@ -990,6 +1048,8 @@ internal enum L10n {
|
|||
internal static let reportIssue = L10n.tr("Localizable", "reportIssue", fallback: "Report an Issue")
|
||||
/// Request a Feature
|
||||
internal static let requestFeature = L10n.tr("Localizable", "requestFeature", fallback: "Request a Feature")
|
||||
/// Required
|
||||
internal static let `required` = L10n.tr("Localizable", "required", fallback: "Required")
|
||||
/// Reset
|
||||
internal static let reset = L10n.tr("Localizable", "reset", fallback: "Reset")
|
||||
/// Reset all settings back to defaults.
|
||||
|
@ -1026,10 +1086,10 @@ internal enum L10n {
|
|||
internal static let run = L10n.tr("Localizable", "run", fallback: "Run")
|
||||
/// Running...
|
||||
internal static let running = L10n.tr("Localizable", "running", fallback: "Running...")
|
||||
/// Run Time
|
||||
internal static let runTime = L10n.tr("Localizable", "runTime", fallback: "Run Time")
|
||||
/// Runtime
|
||||
internal static let runtime = L10n.tr("Localizable", "runtime", fallback: "Runtime")
|
||||
/// Run Time
|
||||
internal static let runTime = L10n.tr("Localizable", "runTime", fallback: "Run Time")
|
||||
/// Save
|
||||
internal static let save = L10n.tr("Localizable", "save", fallback: "Save")
|
||||
/// Scan All Libraries
|
||||
|
@ -1178,6 +1238,8 @@ internal enum L10n {
|
|||
internal static let studio = L10n.tr("Localizable", "studio", fallback: "STUDIO")
|
||||
/// Studios
|
||||
internal static let studios = L10n.tr("Localizable", "studios", fallback: "Studios")
|
||||
/// Studio(s) involved in the creation of media.
|
||||
internal static let studiosDescription = L10n.tr("Localizable", "studiosDescription", fallback: "Studio(s) involved in the creation of media.")
|
||||
/// Subtitle
|
||||
internal static let subtitle = L10n.tr("Localizable", "subtitle", fallback: "Subtitle")
|
||||
/// The subtitle codec is not supported
|
||||
|
@ -1220,6 +1282,8 @@ internal enum L10n {
|
|||
internal static let taglines = L10n.tr("Localizable", "taglines", fallback: "Taglines")
|
||||
/// Tags
|
||||
internal static let tags = L10n.tr("Localizable", "tags", fallback: "Tags")
|
||||
/// Labels used to organize or highlight specific attributes of media.
|
||||
internal static let tagsDescription = L10n.tr("Localizable", "tagsDescription", fallback: "Labels used to organize or highlight specific attributes of media.")
|
||||
/// Task
|
||||
internal static let task = L10n.tr("Localizable", "task", fallback: "Task")
|
||||
/// Aborted
|
||||
|
@ -1270,6 +1334,8 @@ internal enum L10n {
|
|||
internal static let transcodeReasons = L10n.tr("Localizable", "transcodeReasons", fallback: "Transcode Reason(s)")
|
||||
/// Transition
|
||||
internal static let transition = L10n.tr("Localizable", "transition", fallback: "Transition")
|
||||
/// Translator
|
||||
internal static let translator = L10n.tr("Localizable", "translator", fallback: "Translator")
|
||||
/// Trigger already exists
|
||||
internal static let triggerAlreadyExists = L10n.tr("Localizable", "triggerAlreadyExists", fallback: "Trigger already exists")
|
||||
/// Triggers
|
||||
|
@ -1364,8 +1430,12 @@ internal enum L10n {
|
|||
internal static let weekly = L10n.tr("Localizable", "weekly", fallback: "Weekly")
|
||||
/// Who's watching?
|
||||
internal static let whosWatching = L10n.tr("Localizable", "WhosWatching", fallback: "Who's watching?")
|
||||
/// This will be created as a new item on your Jellyfin Server.
|
||||
internal static let willBeCreatedOnServer = L10n.tr("Localizable", "willBeCreatedOnServer", fallback: "This will be created as a new item on your Jellyfin Server.")
|
||||
/// WIP
|
||||
internal static let wip = L10n.tr("Localizable", "wip", fallback: "WIP")
|
||||
/// Writer
|
||||
internal static let writer = L10n.tr("Localizable", "writer", fallback: "Writer")
|
||||
/// Year
|
||||
internal static let year = L10n.tr("Localizable", "year", fallback: "Year")
|
||||
/// Years
|
||||
|
|
|
@ -149,10 +149,10 @@ extension StoredValues.Keys {
|
|||
)
|
||||
}
|
||||
|
||||
static var enableItemEditor: Key<Bool> {
|
||||
static var enableItemEditing: Key<Bool> {
|
||||
CurrentUserKey(
|
||||
"enableItemEditor",
|
||||
domain: "enableItemEditor",
|
||||
"enableItemEditing",
|
||||
domain: "enableItemEditing",
|
||||
default: false
|
||||
)
|
||||
}
|
||||
|
@ -164,5 +164,13 @@ extension StoredValues.Keys {
|
|||
default: false
|
||||
)
|
||||
}
|
||||
|
||||
static var enableCollectionManagement: Key<Bool> {
|
||||
CurrentUserKey(
|
||||
"enableCollectionManagement",
|
||||
domain: "enableCollectionManagement",
|
||||
default: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,13 +64,8 @@ extension UserState {
|
|||
}
|
||||
}
|
||||
|
||||
var isAdministrator: Bool {
|
||||
data.policy?.isAdministrator ?? false
|
||||
}
|
||||
|
||||
// Validate that the use has permission to delete something whether from a folder or all folders
|
||||
var hasDeletionPermissions: Bool {
|
||||
data.policy?.enableContentDeletion ?? false || data.policy?.enableContentDeletionFromFolders != []
|
||||
var permissions: UserPermissions {
|
||||
UserPermissions(data.policy)
|
||||
}
|
||||
|
||||
var pinHint: String {
|
||||
|
|
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
class GenreEditorViewModel: ItemEditorViewModel<String> {
|
||||
|
||||
// MARK: - Populate the Trie
|
||||
|
||||
override func populateTrie() {
|
||||
trie.insert(contentsOf: elements.keyed(using: \.localizedLowercase))
|
||||
}
|
||||
|
||||
// MARK: - Add Genre(s)
|
||||
|
||||
override func addComponents(_ genres: [String]) async throws {
|
||||
var updatedItem = item
|
||||
if updatedItem.genres == nil {
|
||||
updatedItem.genres = []
|
||||
}
|
||||
updatedItem.genres?.append(contentsOf: genres)
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Remove Genre(s)
|
||||
|
||||
override func removeComponents(_ genres: [String]) async throws {
|
||||
var updatedItem = item
|
||||
updatedItem.genres?.removeAll { genres.contains($0) }
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Reorder Tag(s)
|
||||
|
||||
override func reorderComponents(_ genres: [String]) async throws {
|
||||
var updatedItem = item
|
||||
updatedItem.genres = genres
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Fetch All Possible Genres
|
||||
|
||||
override func fetchElements() async throws -> [String] {
|
||||
let request = Paths.getGenres()
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
if let genres = response.value.items {
|
||||
return genres.compactMap(\.name).compactMap { $0 }
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,297 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import OrderedCollections
|
||||
|
||||
class ItemEditorViewModel<Element: Equatable>: ViewModel, Stateful, Eventful {
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
enum Event: Equatable {
|
||||
case updated
|
||||
case loaded
|
||||
case error(JellyfinAPIError)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
enum Action: Equatable {
|
||||
case load
|
||||
case search(String)
|
||||
case add([Element])
|
||||
case remove([Element])
|
||||
case reorder([Element])
|
||||
case update(BaseItemDto)
|
||||
}
|
||||
|
||||
// MARK: BackgroundState
|
||||
|
||||
enum BackgroundState: Hashable {
|
||||
case loading
|
||||
case searching
|
||||
case refreshing
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
enum State: Hashable {
|
||||
case initial
|
||||
case content
|
||||
case updating
|
||||
case error(JellyfinAPIError)
|
||||
}
|
||||
|
||||
@Published
|
||||
var backgroundStates: OrderedSet<BackgroundState> = []
|
||||
@Published
|
||||
var item: BaseItemDto
|
||||
@Published
|
||||
var elements: [Element] = []
|
||||
@Published
|
||||
var matches: [Element] = []
|
||||
@Published
|
||||
var state: State = .initial
|
||||
|
||||
final var trie = Trie<String, Element>()
|
||||
|
||||
private var loadTask: AnyCancellable?
|
||||
private var updateTask: AnyCancellable?
|
||||
private var searchTask: AnyCancellable?
|
||||
private var searchQuery = CurrentValueSubject<String, Never>("")
|
||||
|
||||
private let eventSubject = PassthroughSubject<Event, Never>()
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject.receive(on: RunLoop.main).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
|
||||
super.init()
|
||||
|
||||
setupSearchDebounce()
|
||||
}
|
||||
|
||||
// MARK: - Setup Debouncing
|
||||
|
||||
private func setupSearchDebounce() {
|
||||
searchQuery
|
||||
.debounce(for: .seconds(0.5), scheduler: RunLoop.main)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] searchTerm in
|
||||
guard let self else { return }
|
||||
guard searchTerm.isNotEmpty else { return }
|
||||
|
||||
self.executeSearch(for: searchTerm)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Respond to Actions
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case .load:
|
||||
loadTask?.cancel()
|
||||
|
||||
loadTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
do {
|
||||
await MainActor.run {
|
||||
self.matches = []
|
||||
self.state = .initial
|
||||
_ = self.backgroundStates.append(.loading)
|
||||
}
|
||||
|
||||
let allElements = try await self.fetchElements()
|
||||
|
||||
await MainActor.run {
|
||||
self.elements = allElements
|
||||
self.state = .content
|
||||
self.eventSubject.send(.loaded)
|
||||
|
||||
_ = self.backgroundStates.remove(.loading)
|
||||
}
|
||||
|
||||
populateTrie()
|
||||
|
||||
} catch {
|
||||
let apiError = JellyfinAPIError(error.localizedDescription)
|
||||
await MainActor.run {
|
||||
self.state = .error(apiError)
|
||||
_ = self.backgroundStates.remove(.loading)
|
||||
}
|
||||
}
|
||||
}.asAnyCancellable()
|
||||
|
||||
return state
|
||||
|
||||
case let .search(searchTerm):
|
||||
searchQuery.send(searchTerm)
|
||||
return state
|
||||
|
||||
case let .add(addItems):
|
||||
executeAction {
|
||||
try await self.addComponents(addItems)
|
||||
}
|
||||
return state
|
||||
|
||||
case let .remove(removeItems):
|
||||
executeAction {
|
||||
try await self.removeComponents(removeItems)
|
||||
}
|
||||
return state
|
||||
|
||||
case let .reorder(orderedItems):
|
||||
executeAction {
|
||||
try await self.reorderComponents(orderedItems)
|
||||
}
|
||||
return state
|
||||
|
||||
case let .update(updateItem):
|
||||
executeAction {
|
||||
try await self.updateItem(updateItem)
|
||||
}
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Execute Debounced Search
|
||||
|
||||
private func executeSearch(for searchTerm: String) {
|
||||
searchTask?.cancel()
|
||||
|
||||
searchTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
do {
|
||||
await MainActor.run {
|
||||
_ = self.backgroundStates.append(.searching)
|
||||
}
|
||||
|
||||
let results = try await self.searchElements(searchTerm)
|
||||
|
||||
await MainActor.run {
|
||||
self.matches = results
|
||||
_ = self.backgroundStates.remove(.searching)
|
||||
}
|
||||
} catch {
|
||||
let apiError = JellyfinAPIError(error.localizedDescription)
|
||||
await MainActor.run {
|
||||
self.state = .error(apiError)
|
||||
_ = self.backgroundStates.remove(.searching)
|
||||
}
|
||||
}
|
||||
}.asAnyCancellable()
|
||||
}
|
||||
|
||||
// MARK: - Helper: Execute Task for Add/Remove/Reorder/Update
|
||||
|
||||
private func executeAction(action: @escaping () async throws -> Void) {
|
||||
updateTask?.cancel()
|
||||
|
||||
updateTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
do {
|
||||
await MainActor.run {
|
||||
self.state = .updating
|
||||
}
|
||||
|
||||
try await action()
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
self.eventSubject.send(.updated)
|
||||
}
|
||||
} catch {
|
||||
let apiError = JellyfinAPIError(error.localizedDescription)
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
self.eventSubject.send(.error(apiError))
|
||||
}
|
||||
}
|
||||
}.asAnyCancellable()
|
||||
}
|
||||
|
||||
// MARK: - Save Updated Item to Server
|
||||
|
||||
func updateItem(_ newItem: BaseItemDto) async throws {
|
||||
guard let itemId = item.id else { return }
|
||||
|
||||
let request = Paths.updateItem(itemID: itemId, newItem)
|
||||
_ = try await userSession.client.send(request)
|
||||
|
||||
try await refreshItem()
|
||||
|
||||
await MainActor.run {
|
||||
Notifications[.itemMetadataDidChange].post(object: newItem)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Refresh Item
|
||||
|
||||
private func refreshItem() async throws {
|
||||
guard let itemId = item.id else { return }
|
||||
|
||||
await MainActor.run {
|
||||
_ = self.backgroundStates.append(.refreshing)
|
||||
}
|
||||
|
||||
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
|
||||
_ = self.backgroundStates.remove(.refreshing)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Populate the Trie
|
||||
|
||||
func populateTrie() {
|
||||
fatalError("This method should be overridden in subclasses")
|
||||
}
|
||||
|
||||
// MARK: - Add Element Component to Item (To Be Overridden)
|
||||
|
||||
func addComponents(_ components: [Element]) async throws {
|
||||
fatalError("This method should be overridden in subclasses")
|
||||
}
|
||||
|
||||
// MARK: - Remove Element Component from Item (To Be Overridden)
|
||||
|
||||
func removeComponents(_ components: [Element]) async throws {
|
||||
fatalError("This method should be overridden in subclasses")
|
||||
}
|
||||
|
||||
// MARK: - Reorder Elements (To Be Overridden)
|
||||
|
||||
func reorderComponents(_ tags: [Element]) async throws {
|
||||
fatalError("This method should be overridden in subclasses")
|
||||
}
|
||||
|
||||
// MARK: - Fetch All Possible Elements (To Be Overridden)
|
||||
|
||||
func fetchElements() async throws -> [Element] {
|
||||
fatalError("This method should be overridden in subclasses")
|
||||
}
|
||||
|
||||
// MARK: - Return Matching Elements (To Be Overridden)
|
||||
|
||||
func searchElements(_ searchTerm: String) async throws -> [Element] {
|
||||
trie.search(prefix: searchTerm.localizedLowercase)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
class PeopleEditorViewModel: ItemEditorViewModel<BaseItemPerson> {
|
||||
|
||||
// MARK: - Populate the Trie
|
||||
|
||||
override func populateTrie() {
|
||||
let elements = elements
|
||||
.compacted(using: \.name)
|
||||
.reduce(into: [String: BaseItemPerson]()) { result, element in
|
||||
result[element.name!.localizedLowercase] = element
|
||||
}
|
||||
|
||||
trie.insert(contentsOf: elements)
|
||||
}
|
||||
|
||||
// MARK: - Add People(s)
|
||||
|
||||
override func addComponents(_ people: [BaseItemPerson]) async throws {
|
||||
var updatedItem = item
|
||||
if updatedItem.people == nil {
|
||||
updatedItem.people = []
|
||||
}
|
||||
updatedItem.people?.append(contentsOf: people)
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Remove People(s)
|
||||
|
||||
override func removeComponents(_ people: [BaseItemPerson]) async throws {
|
||||
var updatedItem = item
|
||||
updatedItem.people?.removeAll { people.contains($0) }
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Reorder Tag(s)
|
||||
|
||||
override func reorderComponents(_ people: [BaseItemPerson]) async throws {
|
||||
var updatedItem = item
|
||||
updatedItem.people = people
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Fetch All Possible People
|
||||
|
||||
override func fetchElements() async throws -> [BaseItemPerson] {
|
||||
let request = Paths.getPersons()
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
if let people = response.value.items {
|
||||
return people.map { person in
|
||||
BaseItemPerson(id: person.id, name: person.name)
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
class StudioEditorViewModel: ItemEditorViewModel<NameGuidPair> {
|
||||
|
||||
// MARK: - Populate the Trie
|
||||
|
||||
override func populateTrie() {
|
||||
let elements = elements
|
||||
.compacted(using: \.name)
|
||||
.reduce(into: [String: NameGuidPair]()) { result, element in
|
||||
result[element.name!] = element
|
||||
}
|
||||
|
||||
trie.insert(contentsOf: elements)
|
||||
}
|
||||
|
||||
// MARK: - Add Studio(s)
|
||||
|
||||
override func addComponents(_ studios: [NameGuidPair]) async throws {
|
||||
var updatedItem = item
|
||||
if updatedItem.studios == nil {
|
||||
updatedItem.studios = []
|
||||
}
|
||||
updatedItem.studios?.append(contentsOf: studios)
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Remove Studio(s)
|
||||
|
||||
override func removeComponents(_ studios: [NameGuidPair]) async throws {
|
||||
var updatedItem = item
|
||||
updatedItem.studios?.removeAll { studios.contains($0) }
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Reorder Tag(s)
|
||||
|
||||
override func reorderComponents(_ studios: [NameGuidPair]) async throws {
|
||||
var updatedItem = item
|
||||
updatedItem.studios = studios
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Fetch All Possible Studios
|
||||
|
||||
override func fetchElements() async throws -> [NameGuidPair] {
|
||||
let request = Paths.getStudios()
|
||||
let response = try await userSession.client.send(request)
|
||||
|
||||
if let studios = response.value.items {
|
||||
return studios.map { studio in
|
||||
NameGuidPair(id: studio.id, name: studio.name)
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
class TagEditorViewModel: ItemEditorViewModel<String> {
|
||||
|
||||
// MARK: - Populate the Trie
|
||||
|
||||
override func populateTrie() {
|
||||
trie.insert(contentsOf: elements.keyed(using: \.localizedLowercase))
|
||||
}
|
||||
|
||||
// MARK: - Add Tag(s)
|
||||
|
||||
override func addComponents(_ tags: [String]) async throws {
|
||||
var updatedItem = item
|
||||
if updatedItem.tags == nil {
|
||||
updatedItem.tags = []
|
||||
}
|
||||
updatedItem.tags?.append(contentsOf: tags)
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Remove Tag(s)
|
||||
|
||||
override func removeComponents(_ tags: [String]) async throws {
|
||||
var updatedItem = item
|
||||
updatedItem.tags?.removeAll { tags.contains($0) }
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Reorder Tag(s)
|
||||
|
||||
override func reorderComponents(_ tags: [String]) async throws {
|
||||
var updatedItem = item
|
||||
updatedItem.tags = tags
|
||||
try await updateItem(updatedItem)
|
||||
}
|
||||
|
||||
// MARK: - Fetch All Possible Tags
|
||||
|
||||
override func fetchElements() async throws -> [String] {
|
||||
let parameters = Paths.GetQueryFiltersLegacyParameters(userID: userSession.user.id)
|
||||
let request = Paths.getQueryFiltersLegacy(parameters: parameters)
|
||||
guard let response = try? await userSession.client.send(request) else { return [] }
|
||||
|
||||
return response.value.tags ?? []
|
||||
}
|
||||
}
|
|
@ -1,195 +0,0 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
import OrderedCollections
|
||||
|
||||
class ItemEditorViewModel<ItemType: Equatable>: ViewModel, Stateful, Eventful {
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
enum Event: Equatable {
|
||||
case updated
|
||||
case error(JellyfinAPIError)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
enum Action: Equatable {
|
||||
case add([ItemType])
|
||||
case remove([ItemType])
|
||||
case update(BaseItemDto)
|
||||
}
|
||||
|
||||
// MARK: BackgroundState
|
||||
|
||||
enum BackgroundState: Hashable {
|
||||
case refreshing
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
enum State: Hashable {
|
||||
case initial
|
||||
case error(JellyfinAPIError)
|
||||
case updating
|
||||
}
|
||||
|
||||
@Published
|
||||
var backgroundStates: OrderedSet<BackgroundState> = []
|
||||
|
||||
@Published
|
||||
var item: BaseItemDto
|
||||
|
||||
@Published
|
||||
var state: State = .initial
|
||||
|
||||
private var task: AnyCancellable?
|
||||
private let eventSubject = PassthroughSubject<Event, Never>()
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject.receive(on: RunLoop.main).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Respond to Actions
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case let .add(items):
|
||||
task?.cancel()
|
||||
|
||||
task = Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
await MainActor.run { self.state = .updating }
|
||||
|
||||
try await self.addComponents(items)
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .initial
|
||||
self.eventSubject.send(.updated)
|
||||
}
|
||||
} catch {
|
||||
let apiError = JellyfinAPIError(error.localizedDescription)
|
||||
await MainActor.run {
|
||||
self.state = .error(apiError)
|
||||
self.eventSubject.send(.error(apiError))
|
||||
}
|
||||
}
|
||||
}.asAnyCancellable()
|
||||
|
||||
return state
|
||||
|
||||
case let .remove(items):
|
||||
task?.cancel()
|
||||
|
||||
task = Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
await MainActor.run { self.state = .updating }
|
||||
|
||||
try await self.removeComponents(items)
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .initial
|
||||
self.eventSubject.send(.updated)
|
||||
}
|
||||
} catch {
|
||||
let apiError = JellyfinAPIError(error.localizedDescription)
|
||||
await MainActor.run {
|
||||
self.state = .error(apiError)
|
||||
self.eventSubject.send(.error(apiError))
|
||||
}
|
||||
}
|
||||
}.asAnyCancellable()
|
||||
|
||||
return state
|
||||
|
||||
case let .update(newItem):
|
||||
task?.cancel()
|
||||
|
||||
task = Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
await MainActor.run { self.state = .updating }
|
||||
|
||||
try await self.updateItem(newItem)
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .initial
|
||||
self.eventSubject.send(.updated)
|
||||
}
|
||||
} catch {
|
||||
let apiError = JellyfinAPIError(error.localizedDescription)
|
||||
await MainActor.run {
|
||||
self.state = .error(apiError)
|
||||
self.eventSubject.send(.error(apiError))
|
||||
}
|
||||
}
|
||||
}.asAnyCancellable()
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save Updated Item to Server
|
||||
|
||||
func updateItem(_ newItem: BaseItemDto, refresh: Bool = false) async throws {
|
||||
guard let itemId = item.id else { return }
|
||||
|
||||
let request = Paths.updateItem(itemID: itemId, newItem)
|
||||
_ = try await userSession.client.send(request)
|
||||
|
||||
if refresh {
|
||||
try await refreshItem()
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
Notifications[.itemMetadataDidChange].post(object: newItem)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Refresh Item
|
||||
|
||||
private func refreshItem() async throws {
|
||||
guard let itemId = item.id else { return }
|
||||
|
||||
await MainActor.run {
|
||||
_ = backgroundStates.append(.refreshing)
|
||||
}
|
||||
|
||||
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(.refreshing)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add Items (To be overridden)
|
||||
|
||||
func addComponents(_ items: [ItemType]) async throws {
|
||||
fatalError("This method should be overridden in subclasses")
|
||||
}
|
||||
|
||||
// MARK: - Remove Items (To be overridden)
|
||||
|
||||
func removeComponents(_ items: [ItemType]) async throws {
|
||||
fatalError("This method should be overridden in subclasses")
|
||||
}
|
||||
}
|
|
@ -9,6 +9,8 @@
|
|||
/* Begin PBXBuildFile section */
|
||||
091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; };
|
||||
091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091B5A872683142E00D78B61 /* ServerDiscovery.swift */; };
|
||||
4E01446C2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; };
|
||||
4E01446D2D0292E200193038 /* Trie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E01446B2D0292E000193038 /* Trie.swift */; };
|
||||
4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0195E32CE04678007844F4 /* ItemSection.swift */; };
|
||||
4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; };
|
||||
4E026A8B2CE804E7005471B5 /* ResetUserPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */; };
|
||||
|
@ -46,6 +48,8 @@
|
|||
4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */; };
|
||||
4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; };
|
||||
4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; };
|
||||
4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */; };
|
||||
4E31EFA52CFFFB690053DFE7 /* EditItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */; };
|
||||
4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE562CBED3F300DBD886 /* TimeRow.swift */; };
|
||||
4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE572CBED3F300DBD886 /* TriggerTypeRow.swift */; };
|
||||
4E35CE5E2CBED3F300DBD886 /* AddTaskTriggerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E35CE5A2CBED3F300DBD886 /* AddTaskTriggerView.swift */; };
|
||||
|
@ -60,6 +64,8 @@
|
|||
4E35CE6C2CBEDB7600DBD886 /* 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 */; };
|
||||
4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */; };
|
||||
4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3A24DB2CFE35CC0083A72C /* NameInput.swift */; };
|
||||
4E49DECB2CE54AA200352DCD /* SessionsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECA2CE54A9200352DCD /* SessionsSection.swift */; };
|
||||
4E49DECD2CE54C7A00352DCD /* PermissionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECC2CE54C7200352DCD /* PermissionSection.swift */; };
|
||||
4E49DECF2CE54D3000352DCD /* MaxBitratePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */; };
|
||||
|
@ -75,7 +81,18 @@
|
|||
4E49DEE42CE55FB900352DCD /* SyncPlayUserAccessType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE22CE55FB500352DCD /* SyncPlayUserAccessType.swift */; };
|
||||
4E49DEE62CE5616800352DCD /* UserProfileImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */; };
|
||||
4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB7B33A2CBDE63F004A342E /* ChevronAlertButton.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 */; };
|
||||
4E4E9C6B2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.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 */; };
|
||||
4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */; };
|
||||
4E5071E42CFCEFD3003FA2AD /* AddItemElementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */; };
|
||||
4E5334A22CD1A28700D59FA8 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5334A12CD1A28400D59FA8 /* ActionButton.swift */; };
|
||||
4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; };
|
||||
4E556AB12D036F6900733377 /* UserPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E556AAF2D036F5E00733377 /* UserPermissions.swift */; };
|
||||
4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; };
|
||||
4E63B9FA2C8A5BEF00C25378 /* AdminDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */; };
|
||||
4E63B9FC2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */; };
|
||||
|
@ -185,6 +202,9 @@
|
|||
4EF3D80B2CF7D6670081AD20 /* ServerUserAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */; };
|
||||
4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */; };
|
||||
4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */; };
|
||||
4EFE0C7D2D0156A900D4834D /* PersonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */; };
|
||||
4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7C2D0156A500D4834D /* PersonKind.swift */; };
|
||||
4EFE0C802D02055900D4834D /* ItemArrayElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFE0C7F2D02054300D4834D /* ItemArrayElements.swift */; };
|
||||
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; };
|
||||
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; };
|
||||
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; };
|
||||
|
@ -1126,6 +1146,7 @@
|
|||
|
||||
/* Begin PBXFileReference section */
|
||||
091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
|
||||
4E01446B2D0292E000193038 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = "<group>"; };
|
||||
4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = "<group>"; };
|
||||
4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = "<group>"; };
|
||||
4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = "<group>"; };
|
||||
|
@ -1152,6 +1173,8 @@
|
|||
4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = "<group>"; };
|
||||
4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
|
||||
4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
|
||||
4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementRow.swift; sourceTree = "<group>"; };
|
||||
4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditItemElementView.swift; sourceTree = "<group>"; };
|
||||
4E35CE532CBED3F300DBD886 /* DayOfWeekRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayOfWeekRow.swift; sourceTree = "<group>"; };
|
||||
4E35CE542CBED3F300DBD886 /* IntervalRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntervalRow.swift; sourceTree = "<group>"; };
|
||||
4E35CE552CBED3F300DBD886 /* TimeLimitSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeLimitSection.swift; sourceTree = "<group>"; };
|
||||
|
@ -1163,6 +1186,8 @@
|
|||
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>"; };
|
||||
4E36395A2CC4DF0900110EBC /* APIKeysViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysViewModel.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>"; };
|
||||
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>"; };
|
||||
4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaxBitratePolicy.swift; sourceTree = "<group>"; };
|
||||
|
@ -1173,7 +1198,13 @@
|
|||
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>"; };
|
||||
4E49DEE52CE5616800352DCD /* UserProfileImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileImagePicker.swift; sourceTree = "<group>"; };
|
||||
4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudioEditorViewModel.swift; sourceTree = "<group>"; };
|
||||
4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleEditorViewModel.swift; sourceTree = "<group>"; };
|
||||
4E5071D62CFCEB6F003FA2AD /* TagEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagEditorViewModel.swift; sourceTree = "<group>"; };
|
||||
4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenreEditorViewModel.swift; sourceTree = "<group>"; };
|
||||
4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddItemElementView.swift; sourceTree = "<group>"; };
|
||||
4E5334A12CD1A28400D59FA8 /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = "<group>"; };
|
||||
4E556AAF2D036F5E00733377 /* UserPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissions.swift; sourceTree = "<group>"; };
|
||||
4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
|
||||
4E63B9F42C8A5BEF00C25378 /* AdminDashboardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdminDashboardView.swift; sourceTree = "<group>"; };
|
||||
4E63B9FB2C8A5C3E00C25378 /* ActiveSessionsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActiveSessionsViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -1265,6 +1296,8 @@
|
|||
4EF3D8092CF7D6670081AD20 /* ServerUserAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerUserAccessView.swift; sourceTree = "<group>"; };
|
||||
4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionMenu.swift; sourceTree = "<group>"; };
|
||||
4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreButton.swift; sourceTree = "<group>"; };
|
||||
4EFE0C7C2D0156A500D4834D /* PersonKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonKind.swift; sourceTree = "<group>"; };
|
||||
4EFE0C7F2D02054300D4834D /* ItemArrayElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemArrayElements.swift; sourceTree = "<group>"; };
|
||||
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||
531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; };
|
||||
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -2090,6 +2123,32 @@
|
|||
path = MediaComponents;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E31EF972CFFB9B70053DFE7 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E3A24DB2CFE35CC0083A72C /* NameInput.swift */,
|
||||
4E3A24D92CFE349A0083A72C /* SearchResultsSection.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E31EFA22CFFFB410053DFE7 /* EditItemElementView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E31EFA42CFFFB670053DFE7 /* EditItemElementView.swift */,
|
||||
4E31EFA32CFFFB480053DFE7 /* Components */,
|
||||
);
|
||||
path = EditItemElementView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E31EFA32CFFFB480053DFE7 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E31EFA02CFFFB180053DFE7 /* EditItemElementRow.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E35CE592CBED3F300DBD886 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2146,6 +2205,27 @@
|
|||
path = UserProfileImagePicker;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E5071D92CFCEC0E003FA2AD /* GenreEditorViewModel.swift */,
|
||||
4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */,
|
||||
4E4E9C692CFEDC9D00A6946F /* PeopleEditorViewModel.swift */,
|
||||
4E4E9C662CFEBF2500A6946F /* StudioEditorViewModel.swift */,
|
||||
4E5071D62CFCEB6F003FA2AD /* TagEditorViewModel.swift */,
|
||||
);
|
||||
path = ItemEditorViewModel;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E5071E22CFCEFC3003FA2AD /* AddItemElementView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E5071E32CFCEFD1003FA2AD /* AddItemElementView.swift */,
|
||||
4E31EF972CFFB9B70053DFE7 /* Components */,
|
||||
);
|
||||
path = AddItemElementView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E5334A02CD1A27C00D59FA8 /* ActionButtons */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2287,7 +2367,9 @@
|
|||
4E8F74A32CE03D3100CC8969 /* ItemEditorView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E5071E22CFCEFC3003FA2AD /* AddItemElementView */,
|
||||
4E8F74A62CE03D4C00CC8969 /* Components */,
|
||||
4E31EFA22CFFFB410053DFE7 /* EditItemElementView */,
|
||||
4E6619FF2CEFE39000025C99 /* EditMetadataView */,
|
||||
4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */,
|
||||
);
|
||||
|
@ -2302,14 +2384,14 @@
|
|||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */ = {
|
||||
4E8F74A92CE03DBE00CC8969 /* ItemAdministration */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */,
|
||||
4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */,
|
||||
4E5071D52CFCEB03003FA2AD /* ItemEditorViewModel */,
|
||||
4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */,
|
||||
);
|
||||
path = ItemEditorViewModel;
|
||||
path = ItemAdministration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E90F75E2CC72B1F00417C31 /* Sections */ = {
|
||||
|
@ -2561,7 +2643,7 @@
|
|||
E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */,
|
||||
E113133928BEB71D00930F75 /* FilterViewModel.swift */,
|
||||
625CB5722678C32A00530A6E /* HomeViewModel.swift */,
|
||||
4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */,
|
||||
4E8F74A92CE03DBE00CC8969 /* ItemAdministration */,
|
||||
E107BB9127880A4000354E07 /* ItemViewModel */,
|
||||
E1EDA8D52B924CA500F9A57E /* LibraryViewModel */,
|
||||
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */,
|
||||
|
@ -2679,6 +2761,7 @@
|
|||
E1579EA62B97DC1500A31CA1 /* Eventful.swift */,
|
||||
E1092F4B29106F9F00163F57 /* GestureAction.swift */,
|
||||
E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */,
|
||||
4EFE0C7F2D02054300D4834D /* ItemArrayElements.swift */,
|
||||
E14EDECA2B8FB66F000F00A4 /* ItemFilter */,
|
||||
E1C925F328875037002A7A66 /* ItemViewType.swift */,
|
||||
E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */,
|
||||
|
@ -2707,7 +2790,9 @@
|
|||
E1E306CC28EF6E8000537998 /* TimerProxy.swift */,
|
||||
E129428F28F0BDC300796AC6 /* TimeStampType.swift */,
|
||||
E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */,
|
||||
4E01446B2D0292E000193038 /* Trie.swift */,
|
||||
E1EA09682BED78BB004CDE76 /* UserAccessPolicy.swift */,
|
||||
4E556AAF2D036F5E00733377 /* UserPermissions.swift */,
|
||||
DFB7C3DE2C7AA42700CE7CDC /* UserSignInState.swift */,
|
||||
E1D8429229340B8300D1041A /* Utilities.swift */,
|
||||
E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */,
|
||||
|
@ -4097,6 +4182,7 @@
|
|||
E122A9122788EAAD0060FA63 /* MediaStream.swift */,
|
||||
4E661A2D2CEFE77700025C99 /* MetadataField.swift */,
|
||||
E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */,
|
||||
4EFE0C7C2D0156A500D4834D /* PersonKind.swift */,
|
||||
E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */,
|
||||
4E2182E42CAF67EF0094806B /* PlayMethod.swift */,
|
||||
4E35CE652CBED8B300DBD886 /* ServerTicks.swift */,
|
||||
|
@ -4921,6 +5007,7 @@
|
|||
E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */,
|
||||
E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */,
|
||||
E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */,
|
||||
4E01446C2D0292E200193038 /* Trie.swift in Sources */,
|
||||
E14EA16A2BF7333B00DE757A /* UserProfileImageViewModel.swift in Sources */,
|
||||
4EBE06542C7ED0E1004A6C03 /* DeviceProfile.swift in Sources */,
|
||||
E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */,
|
||||
|
@ -4996,6 +5083,7 @@
|
|||
E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */,
|
||||
E17DC74B2BE740D900B42379 /* StoredValues+Server.swift in Sources */,
|
||||
4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */,
|
||||
4E5071D82CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */,
|
||||
E10E842A29A587110064EA49 /* LoadingView.swift in Sources */,
|
||||
E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */,
|
||||
E13316FF2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */,
|
||||
|
@ -5022,6 +5110,7 @@
|
|||
E1763A722BF3F67C004DF6AB /* SwiftfinStore+Mappings.swift in Sources */,
|
||||
E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
|
||||
E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */,
|
||||
4E5071DB2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */,
|
||||
4E4A53222CBE0A1C003BD24D /* ChevronAlertButton.swift in Sources */,
|
||||
E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */,
|
||||
4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */,
|
||||
|
@ -5056,6 +5145,7 @@
|
|||
E11042762B8013DF00821020 /* Stateful.swift in Sources */,
|
||||
091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */,
|
||||
E1575E66293E77B5001665B1 /* Poster.swift in Sources */,
|
||||
4E4E9C682CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */,
|
||||
E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */,
|
||||
E19D41B42BF2C0020082B8B2 /* StoredValues+Temp.swift in Sources */,
|
||||
4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */,
|
||||
|
@ -5082,6 +5172,7 @@
|
|||
E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */,
|
||||
4E661A1F2CEFE56E00025C99 /* SeriesDisplayOrder.swift in Sources */,
|
||||
E145EB462BE0AD4E003BF6F3 /* Set.swift in Sources */,
|
||||
4EFE0C7D2D0156A900D4834D /* PersonKind.swift in Sources */,
|
||||
E1575E7D293E77B5001665B1 /* PosterDisplayType.swift in Sources */,
|
||||
E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */,
|
||||
E18A17F2298C68BB00C22F62 /* MainOverlay.swift in Sources */,
|
||||
|
@ -5093,10 +5184,12 @@
|
|||
E148128628C15475003B8787 /* SortOrder+ItemSortOrder.swift in Sources */,
|
||||
E1CB75722C80E71800217C76 /* DirectPlayProfile.swift in Sources */,
|
||||
E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */,
|
||||
4E4E9C6B2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */,
|
||||
E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */,
|
||||
E133328929538D8D00EE76AB /* Files.swift in Sources */,
|
||||
E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */,
|
||||
C46008742A97DFF2002B1C7A /* LiveLoadingOverlay.swift in Sources */,
|
||||
4E556AB12D036F6900733377 /* UserPermissions.swift in Sources */,
|
||||
E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */,
|
||||
E1C926132887565C002A7A66 /* EpisodeSelector.swift in Sources */,
|
||||
E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */,
|
||||
|
@ -5218,6 +5311,7 @@
|
|||
C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */,
|
||||
5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */,
|
||||
E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */,
|
||||
4E5071D72CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */,
|
||||
E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */,
|
||||
E146A9D82BE6E9830034DA1E /* StoredValue.swift in Sources */,
|
||||
6220D0B426D5ED8000B8E046 /* LibraryCoordinator.swift in Sources */,
|
||||
|
@ -5282,6 +5376,7 @@
|
|||
62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */,
|
||||
E1B4E4382CA7795200DC49DE /* OrderedDictionary.swift in Sources */,
|
||||
E18E01E8288747230022598C /* SeriesItemContentView.swift in Sources */,
|
||||
4E5071E42CFCEFD3003FA2AD /* AddItemElementView.swift in Sources */,
|
||||
E16AA60828A364A6009A983C /* PosterButton.swift in Sources */,
|
||||
E1E1644128BB301900323B0A /* Array.swift in Sources */,
|
||||
E18CE0AF28A222240092E7F1 /* PublicUserRow.swift in Sources */,
|
||||
|
@ -5294,6 +5389,7 @@
|
|||
E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */,
|
||||
4EBE06532C7ED0E1004A6C03 /* DeviceProfile.swift in Sources */,
|
||||
E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */,
|
||||
4E3A24DA2CFE34A00083A72C /* SearchResultsSection.swift in Sources */,
|
||||
E150C0BA2BFD44F500944FFA /* ImagePipeline.swift in Sources */,
|
||||
E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */,
|
||||
E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */,
|
||||
|
@ -5446,6 +5542,7 @@
|
|||
4E90F76A2CC72B1F00417C31 /* DetailsSection.swift in Sources */,
|
||||
E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */,
|
||||
C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */,
|
||||
4EFE0C7E2D0156A900D4834D /* PersonKind.swift in Sources */,
|
||||
E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */,
|
||||
E139CC1F28EC83E400688DE2 /* Int.swift in Sources */,
|
||||
E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */,
|
||||
|
@ -5465,6 +5562,7 @@
|
|||
4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */,
|
||||
E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */,
|
||||
4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */,
|
||||
4E31EFA12CFFFB1D0053DFE7 /* EditItemElementRow.swift in Sources */,
|
||||
E1BE1CEA2BDB5AFE008176A9 /* UserGridButton.swift in Sources */,
|
||||
E1401CB129386C9200E8B599 /* UIColor.swift in Sources */,
|
||||
E1E2F8452B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */,
|
||||
|
@ -5481,6 +5579,7 @@
|
|||
E18E01AD288746AF0022598C /* DotHStack.swift in Sources */,
|
||||
E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */,
|
||||
E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */,
|
||||
4E01446D2D0292E200193038 /* Trie.swift in Sources */,
|
||||
E1937A61288F32DB00CB80AA /* Poster.swift in Sources */,
|
||||
4E2182E62CAF67F50094806B /* PlayMethod.swift in Sources */,
|
||||
E145EB482BE0C136003BF6F3 /* ScrollIfLargerThanContainerModifier.swift in Sources */,
|
||||
|
@ -5561,6 +5660,7 @@
|
|||
E101ECD52CD40489001EA89E /* DeviceDetailViewModel.swift in Sources */,
|
||||
E18E01E2288747230022598C /* EpisodeItemView.swift in Sources */,
|
||||
4E35CE5C2CBED3F300DBD886 /* TimeRow.swift in Sources */,
|
||||
4E3A24DC2CFE35D50083A72C /* NameInput.swift in Sources */,
|
||||
4E35CE5D2CBED3F300DBD886 /* TriggerTypeRow.swift in Sources */,
|
||||
4E49DED32CE54D6D00352DCD /* ActiveSessionsPolicy.swift in Sources */,
|
||||
4E35CE5E2CBED3F300DBD886 /* AddTaskTriggerView.swift in Sources */,
|
||||
|
@ -5573,6 +5673,7 @@
|
|||
4E6619FD2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */,
|
||||
E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */,
|
||||
E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */,
|
||||
4E5071DA2CFCEC1D003FA2AD /* GenreEditorViewModel.swift in Sources */,
|
||||
E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */,
|
||||
E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */,
|
||||
4E661A202CEFE56E00025C99 /* SeriesDisplayOrder.swift in Sources */,
|
||||
|
@ -5691,6 +5792,7 @@
|
|||
E10B1EC72BD9AF6100A92EAF /* V2ServerModel.swift in Sources */,
|
||||
E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */,
|
||||
4EBE06462C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */,
|
||||
4EFE0C802D02055900D4834D /* ItemArrayElements.swift in Sources */,
|
||||
C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */,
|
||||
E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */,
|
||||
E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */,
|
||||
|
@ -5699,6 +5801,7 @@
|
|||
E1D27EE72BBC955F00152D16 /* UnmaskSecureField.swift in Sources */,
|
||||
E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */,
|
||||
E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */,
|
||||
4E556AB02D036F6900733377 /* UserPermissions.swift in Sources */,
|
||||
E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */,
|
||||
4EC2B19B2CC96E7400D866BE /* ServerUsersView.swift in Sources */,
|
||||
E18E01F1288747230022598C /* PlayButton.swift in Sources */,
|
||||
|
@ -5750,6 +5853,7 @@
|
|||
4E8F74AF2CE03E2E00CC8969 /* RefreshMetadataButton.swift in Sources */,
|
||||
E148128528C15472003B8787 /* SortOrder+ItemSortOrder.swift in Sources */,
|
||||
E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */,
|
||||
4E4E9C6A2CFEDCA400A6946F /* PeopleEditorViewModel.swift in Sources */,
|
||||
E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */,
|
||||
E1A3E4C72BB74E50005C59F8 /* EpisodeCard.swift in Sources */,
|
||||
E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */,
|
||||
|
@ -5763,6 +5867,8 @@
|
|||
DFB7C3DF2C7AA43A00CE7CDC /* UserSignInState.swift in Sources */,
|
||||
E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */,
|
||||
4E16FD572C01A32700110147 /* LetterPickerOrientation.swift in Sources */,
|
||||
4E31EFA52CFFFB690053DFE7 /* EditItemElementView.swift in Sources */,
|
||||
4E4E9C672CFEBF2A00A6946F /* StudioEditorViewModel.swift in Sources */,
|
||||
E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */,
|
||||
5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */,
|
||||
4EB538C82CE3E8A600EB72D5 /* RemoteControlSection.swift in Sources */,
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct AddItemElementView<Element: Hashable>: View {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: BasicNavigationViewCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
var viewModel: ItemEditorViewModel<Element>
|
||||
|
||||
let type: ItemArrayElements
|
||||
|
||||
@State
|
||||
private var id: String?
|
||||
@State
|
||||
private var name: String = ""
|
||||
@State
|
||||
private var personKind: PersonKind = .unknown
|
||||
@State
|
||||
private var personRole: String = ""
|
||||
|
||||
@State
|
||||
private var loaded: Bool = false
|
||||
|
||||
@State
|
||||
private var isPresentingError: Bool = false
|
||||
@State
|
||||
private var error: Error?
|
||||
|
||||
// MARK: - Name is Valid
|
||||
|
||||
private var isValid: Bool {
|
||||
name.isNotEmpty
|
||||
}
|
||||
|
||||
private var itemAlreadyExists: Bool {
|
||||
viewModel.trie.contains(key: name.localizedLowercase)
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch viewModel.state {
|
||||
case let .error(error):
|
||||
ErrorView(error: error)
|
||||
case .updating:
|
||||
DelayedProgressView()
|
||||
case .initial, .content:
|
||||
contentView
|
||||
}
|
||||
}
|
||||
.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
|
||||
isPresentingError = true
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
L10n.error,
|
||||
isPresented: $isPresentingError,
|
||||
presenting: error
|
||||
) { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content View
|
||||
|
||||
private var contentView: some View {
|
||||
List {
|
||||
NameInput(
|
||||
name: $name,
|
||||
type: type,
|
||||
personKind: $personKind,
|
||||
personRole: $personRole,
|
||||
itemAlreadyExists: itemAlreadyExists
|
||||
)
|
||||
|
||||
SearchResultsSection(
|
||||
id: $id,
|
||||
name: $name,
|
||||
type: type,
|
||||
population: viewModel.matches,
|
||||
isSearching: viewModel.backgroundStates.contains(.searching)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension AddItemElementView {
|
||||
|
||||
struct NameInput: View {
|
||||
|
||||
@Binding
|
||||
var name: String
|
||||
var type: ItemArrayElements
|
||||
|
||||
@Binding
|
||||
var personKind: PersonKind
|
||||
@Binding
|
||||
var personRole: String
|
||||
|
||||
let itemAlreadyExists: Bool
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
nameView
|
||||
|
||||
if type == .people {
|
||||
personView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Name Input Field
|
||||
|
||||
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 Input Fields
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension AddItemElementView {
|
||||
|
||||
struct SearchResultsSection: View {
|
||||
|
||||
@Binding
|
||||
var id: String?
|
||||
@Binding
|
||||
var name: String
|
||||
|
||||
let type: ItemArrayElements
|
||||
let population: [Element]
|
||||
let isSearching: Bool
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
if name.isNotEmpty {
|
||||
Section {
|
||||
if population.isNotEmpty {
|
||||
resultsView
|
||||
.animation(.easeInOut, value: population.count)
|
||||
} else if !isSearching {
|
||||
noResultsView
|
||||
.transition(.opacity)
|
||||
.animation(.easeInOut, value: population.count)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text(L10n.existingItems)
|
||||
if isSearching {
|
||||
DelayedProgressView()
|
||||
} else {
|
||||
Text("-")
|
||||
Text(population.count.description)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut, value: isSearching)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty Matches Results
|
||||
|
||||
private var noResultsView: some View {
|
||||
Text(L10n.none)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
// MARK: - Formatted Matches Results
|
||||
|
||||
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: - Element Matches Button Label by Type
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
extension EditItemElementView {
|
||||
|
||||
struct EditItemElementRow: View {
|
||||
|
||||
@Environment(\.isEditing)
|
||||
var isEditing
|
||||
@Environment(\.isSelected)
|
||||
var isSelected
|
||||
|
||||
let item: Element
|
||||
let type: ItemArrayElements
|
||||
let onSelect: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) {
|
||||
if type == .people {
|
||||
personImage
|
||||
}
|
||||
} content: {
|
||||
rowContent
|
||||
}
|
||||
.isSeparatorVisible(false)
|
||||
.onSelect(perform: onSelect)
|
||||
.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(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,229 @@
|
|||
//
|
||||
// Swiftfin is subject to the terms of the Mozilla Public
|
||||
// License, v2.0. If a copy of the MPL was not distributed with this
|
||||
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
//
|
||||
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct EditItemElementView<Element: Hashable>: View {
|
||||
|
||||
@Default(.accentColor)
|
||||
private var accentColor
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: ItemEditorCoordinator.Router
|
||||
|
||||
@ObservedObject
|
||||
var viewModel: ItemEditorViewModel<Element>
|
||||
|
||||
@State
|
||||
private var elements: [Element]
|
||||
|
||||
private let type: ItemArrayElements
|
||||
private let route: (ItemEditorCoordinator.Router, ItemEditorViewModel<Element>) -> Void
|
||||
|
||||
@State
|
||||
private var isPresentingDeleteConfirmation = false
|
||||
@State
|
||||
private var isPresentingDeleteSelectionConfirmation = false
|
||||
@State
|
||||
private var selectedElements: Set<Element> = []
|
||||
@State
|
||||
private var isEditing: Bool = false
|
||||
@State
|
||||
private var isReordering: Bool = false
|
||||
|
||||
// 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 {
|
||||
contentView
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
.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: - Navigation Bar Select/Remove All Content
|
||||
|
||||
@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: - 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))
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -51,7 +51,7 @@ struct ItemEditorView: View {
|
|||
private var refreshButtonView: some View {
|
||||
Section {
|
||||
RefreshMetadataButton(item: viewModel.item)
|
||||
.environment(\.isEnabled, userSession?.user.isAdministrator ?? false)
|
||||
.environment(\.isEnabled, userSession?.user.permissions.isAdministrator ?? false)
|
||||
} footer: {
|
||||
LearnMoreButton(L10n.metadata) {
|
||||
TextPair(
|
||||
|
@ -82,5 +82,24 @@ struct ItemEditorView: View {
|
|||
router.route(to: \.editMetadata, viewModel.item)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ChevronButton(L10n.genres)
|
||||
.onSelect {
|
||||
router.route(to: \.editGenres, viewModel.item)
|
||||
}
|
||||
ChevronButton(L10n.people)
|
||||
.onSelect {
|
||||
router.route(to: \.editPeople, viewModel.item)
|
||||
}
|
||||
ChevronButton(L10n.tags)
|
||||
.onSelect {
|
||||
router.route(to: \.editTags, viewModel.item)
|
||||
}
|
||||
ChevronButton(L10n.studios)
|
||||
.onSelect {
|
||||
router.route(to: \.editStudios, viewModel.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,21 +32,31 @@ struct ItemView: View {
|
|||
|
||||
@StoredValue(.User.enableItemDeletion)
|
||||
private var enableItemDeletion: Bool
|
||||
@StoredValue(.User.enableItemEditor)
|
||||
private var enableItemEditor: Bool
|
||||
@StoredValue(.User.enableItemEditing)
|
||||
private var enableItemEditing: Bool
|
||||
@StoredValue(.User.enableCollectionManagement)
|
||||
private var enableCollectionManagement: Bool
|
||||
|
||||
private var canDelete: Bool {
|
||||
enableItemDeletion && viewModel.item.canDelete ?? false
|
||||
if viewModel.item.type == .boxSet {
|
||||
return enableCollectionManagement && viewModel.item.canDelete ?? false
|
||||
} else {
|
||||
return enableItemDeletion && viewModel.item.canDelete ?? false
|
||||
}
|
||||
}
|
||||
|
||||
private var canDownload: Bool {
|
||||
viewModel.item.canDownload ?? false
|
||||
private var canEdit: Bool {
|
||||
if viewModel.item.type == .boxSet {
|
||||
return enableCollectionManagement
|
||||
} else {
|
||||
return enableItemEditing
|
||||
}
|
||||
}
|
||||
|
||||
// Use to hide the menu button when not needed.
|
||||
// Add more checks as needed. For example, canDownload.
|
||||
private var enableMenu: Bool {
|
||||
canDelete || enableItemEditor
|
||||
canDelete || canEdit
|
||||
}
|
||||
|
||||
private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel {
|
||||
|
@ -132,7 +142,7 @@ struct ItemView: View {
|
|||
isLoading: viewModel.backgroundStates.contains(.refresh),
|
||||
isHidden: !enableMenu
|
||||
) {
|
||||
if enableItemEditor {
|
||||
if canEdit {
|
||||
Button(L10n.edit, systemImage: "pencil") {
|
||||
router.route(to: \.itemEditor, viewModel)
|
||||
}
|
||||
|
|
|
@ -64,9 +64,9 @@ extension PagingLibraryView {
|
|||
viewType = .grid
|
||||
} label: {
|
||||
if viewType == .grid {
|
||||
Label("Grid", systemImage: "checkmark")
|
||||
Label(L10n.grid, systemImage: "checkmark")
|
||||
} else {
|
||||
Label("Grid", systemImage: "square.grid.2x2.fill")
|
||||
Label(L10n.grid, systemImage: "square.grid.2x2.fill")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -74,9 +74,9 @@ extension PagingLibraryView {
|
|||
viewType = .list
|
||||
} label: {
|
||||
if viewType == .list {
|
||||
Label("List", systemImage: "checkmark")
|
||||
Label(L10n.list, systemImage: "checkmark")
|
||||
} else {
|
||||
Label("List", systemImage: "square.fill.text.grid.1x2")
|
||||
Label(L10n.list, systemImage: "square.fill.text.grid.1x2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,21 +17,39 @@ extension CustomizeViewsSettings {
|
|||
@Injected(\.currentUserSession)
|
||||
private var userSession
|
||||
|
||||
@StoredValue(.User.enableItemEditor)
|
||||
private var enableItemEditor
|
||||
@StoredValue(.User.enableItemEditing)
|
||||
private var enableItemEditing
|
||||
@StoredValue(.User.enableItemDeletion)
|
||||
private var enableItemDeletion
|
||||
@StoredValue(.User.enableCollectionManagement)
|
||||
private var enableCollectionManagement
|
||||
|
||||
var body: some View {
|
||||
Section(L10n.items) {
|
||||
|
||||
if userSession?.user.isAdministrator ?? false {
|
||||
Toggle(L10n.allowItemEditing, isOn: $enableItemEditor)
|
||||
/// Enable Editing Items from All Visible LIbraries
|
||||
if userSession?.user.permissions.items.canEditMetadata ?? false {
|
||||
Toggle(L10n.allowItemEditing, isOn: $enableItemEditing)
|
||||
}
|
||||
|
||||
if userSession?.user.hasDeletionPermissions ?? false {
|
||||
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion)
|
||||
/// Enable Downloading All Items
|
||||
/* if userSession?.user.permissions.items.canDownload ?? false {
|
||||
Toggle(L10n.allowItemDownloading, isOn: $enableItemDownloads)
|
||||
} */
|
||||
/// Enable Deleting or Editing Collections
|
||||
if userSession?.user.permissions.items.canManageCollections ?? false {
|
||||
Toggle(L10n.allowCollectionManagement, isOn: $enableCollectionManagement)
|
||||
}
|
||||
/// Manage Item Lyrics
|
||||
/* if userSession?.user.permissions.items.canManageLyrics ?? false {
|
||||
Toggle(L10n.allowLyricsManagement isOn: $enableLyricsManagement)
|
||||
} */
|
||||
/// Manage Item Subtitles
|
||||
/* if userSession?.user.items.canManageSubtitles ?? false {
|
||||
Toggle(L10n.allowSubtitleManagement, isOn: $enableSubtitleManagement)
|
||||
} */
|
||||
}
|
||||
/// Enable Deleting Items from Approved Libraries
|
||||
if userSession?.user.permissions.items.canDelete ?? false {
|
||||
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ struct SettingsView: View {
|
|||
router.route(to: \.serverConnection, viewModel.userSession.server)
|
||||
}
|
||||
|
||||
if viewModel.userSession.user.isAdministrator {
|
||||
if viewModel.userSession.user.permissions.isAdministrator {
|
||||
ChevronButton(L10n.dashboard)
|
||||
.onSelect {
|
||||
router.route(to: \.adminDashboard)
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
"connectToJellyfin" = "Connect to Jellyfin";
|
||||
"connectToServer" = "Connect to Server";
|
||||
"continueWatching" = "Continue Watching";
|
||||
"director" = "DIRECTOR";
|
||||
"discoveredServers" = "Discovered Servers";
|
||||
"displayOrder" = "Display order";
|
||||
"emptyNextUp" = "Empty Next Up";
|
||||
|
@ -1454,6 +1453,10 @@
|
|||
// Toggle option for enabling media item editing
|
||||
"allowItemEditing" = "Allow media item editing";
|
||||
|
||||
// Allow Collection Management - Toggle
|
||||
// Toggle option for enabling collection editing / deletion
|
||||
"allowCollectionManagement" = "Allow collection management";
|
||||
|
||||
// Allow Media Item Deletion - Toggle
|
||||
// Toggle option for enabling media item deletion
|
||||
"allowItemDeletion" = "Allow media item deletion";
|
||||
|
@ -1882,6 +1885,50 @@
|
|||
// Explanation of custom connections policy
|
||||
"customConnectionsDescription" = "Manually set the maximum number of connections a user can have to the server.";
|
||||
|
||||
// Required - Validation
|
||||
// Indicates a field is required
|
||||
"required" = "Required";
|
||||
|
||||
// Reorder - Menu Option
|
||||
// Menu option to allow for reorder items in a set or array
|
||||
"reorder" = "Reorder";
|
||||
|
||||
// Exists on Server - Validation
|
||||
// Indicates a specific item exists on your Jellyfin Server
|
||||
"existsOnServer" = "This item exists on your Jellyfin Server.";
|
||||
|
||||
// Will Be Created on Server - Notification
|
||||
// Indicates a specific item will be created as new on your Jellyfin Server
|
||||
"willBeCreatedOnServer" = "This will be created as a new item on your Jellyfin Server.";
|
||||
|
||||
// Genres - Description
|
||||
// A brief explanation of genres in the context of media items
|
||||
"genresDescription" = "Categories that describe the themes or styles of media.";
|
||||
|
||||
// Tags - Description
|
||||
// A brief explanation of tags in the context of media items
|
||||
"tagsDescription" = "Labels used to organize or highlight specific attributes of media.";
|
||||
|
||||
// Studios - Description
|
||||
// A brief explanation of studios in the context of media items
|
||||
"studiosDescription" = "Studio(s) involved in the creation of media.";
|
||||
|
||||
// People - Description
|
||||
// A brief explanation of tags in the people of media items
|
||||
"peopleDescription" = "People who helped create or perform specific media.";
|
||||
|
||||
// Delete Item - Confirmation
|
||||
// Asks the user to confirm the deletion of a single item
|
||||
"deleteItemConfirmation" = "Are you sure you want to delete this item?";
|
||||
|
||||
// Delete Selected Items - Confirmation
|
||||
// Asks the user to confirm the deletion of selected item
|
||||
"deleteSelectedConfirmation" = "Are you sure you want to delete the selected items?";
|
||||
|
||||
// Existing items - Section Title
|
||||
// Section for Items that already exist on the Jellyfin Server
|
||||
"existingItems" = "Existing items";
|
||||
|
||||
// Enable All Libraries - Toggle
|
||||
// Toggle to enable a setting for all Libraries
|
||||
"enableAllLibraries" = "Enable all libraries";
|
||||
|
@ -1897,3 +1944,99 @@
|
|||
// Access - Section Description
|
||||
// Section Title for Media Access
|
||||
"access" = "Access";
|
||||
|
||||
// Actor - Enum
|
||||
// Represents an actor
|
||||
"actor" = "Actor";
|
||||
|
||||
// Composer - Enum
|
||||
// Represents a composer
|
||||
"composer" = "Composer";
|
||||
|
||||
// Director - Enum
|
||||
// Represents a director
|
||||
"director" = "Director";
|
||||
|
||||
// Writer - Enum
|
||||
// Represents a writer
|
||||
"writer" = "Writer";
|
||||
|
||||
// Guest Star - Enum
|
||||
// Represents a guest star
|
||||
"guestStar" = "Guest Star";
|
||||
|
||||
// Producer - Enum
|
||||
// Represents a producer
|
||||
"producer" = "Producer";
|
||||
|
||||
// Conductor - Enum
|
||||
// Represents a conductor
|
||||
"conductor" = "Conductor";
|
||||
|
||||
// Lyricist - Enum
|
||||
// Represents a lyricist
|
||||
"lyricist" = "Lyricist";
|
||||
|
||||
// Arranger - Enum
|
||||
// Represents an arranger
|
||||
"arranger" = "Arranger";
|
||||
|
||||
// Engineer - Enum
|
||||
// Represents an engineer
|
||||
"engineer" = "Engineer";
|
||||
|
||||
// Mixer - Enum
|
||||
// Represents a mixer
|
||||
"mixer" = "Mixer";
|
||||
|
||||
// Remixer - Enum
|
||||
// Represents a remixer
|
||||
"remixer" = "Remixer";
|
||||
|
||||
// Creator - Enum
|
||||
// Represents a creator
|
||||
"creator" = "Creator";
|
||||
|
||||
// Artist - Enum
|
||||
// Represents an artist
|
||||
"artist" = "Artist";
|
||||
|
||||
// Album Artist - Enum
|
||||
// Represents an album artist
|
||||
"albumArtist" = "Album Artist";
|
||||
|
||||
// Author - Enum
|
||||
// Represents an author
|
||||
"author" = "Author";
|
||||
|
||||
// Illustrator - Enum
|
||||
// Represents an illustrator
|
||||
"illustrator" = "Illustrator";
|
||||
|
||||
// Penciller - Enum
|
||||
// Represents a penciller
|
||||
"penciller" = "Penciller";
|
||||
|
||||
// Inker - Enum
|
||||
// Represents an inker
|
||||
"inker" = "Inker";
|
||||
|
||||
// Colorist - Enum
|
||||
// Represents a colorist
|
||||
"colorist" = "Colorist";
|
||||
|
||||
// Letterer - Enum
|
||||
// Represents a letterer
|
||||
"letterer" = "Letterer";
|
||||
|
||||
// Cover Artist - Enum
|
||||
// Represents a cover artist
|
||||
"coverArtist" = "Cover Artist";
|
||||
|
||||
// Editor - Enum
|
||||
// Represents an editor
|
||||
"editor" = "Editor";
|
||||
|
||||
// Translator - Enum
|
||||
// Represents a translator
|
||||
"translator" = "Translator";
|
||||
|
|
Loading…
Reference in New Issue