[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:
Joe Kribs 2024-12-06 15:22:11 -07:00 committed by GitHub
parent 95c4395c11
commit a3d84a958f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2063 additions and 237 deletions

View File

@ -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)

View File

@ -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) })
}
}

View File

@ -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
}
}
}

View File

@ -0,0 +1,112 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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
}
}
}

View File

@ -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
}
}

69
Shared/Objects/Trie.swift Normal file
View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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
)
}
}
}

View File

@ -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 {

View File

@ -0,0 +1,60 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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 []
}
}
}

View File

@ -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)
}
}

View File

@ -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 []
}
}
}

View File

@ -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 []
}
}
}

View File

@ -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 ?? []
}
}

View File

@ -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")
}
}

View File

@ -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 */,

View File

@ -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)
)
}
}
}

View File

@ -0,0 +1,86 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 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()
}
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)
}

View File

@ -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")
}
}
}

View File

@ -17,22 +17,40 @@ 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 {
/// 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)
}
}
}
}
}

View File

@ -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)

View File

@ -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";