[iOS] Media Item Menu | Edit Metadata (#1323)

* Playback Quality - Learn More

* TODO: Fix leading not working on second line.

* Remove layoutDirection.

* Implement for tvOS. Slightly different spacing.

* VStack

* WIP - tvOS Implementaiton. SUBJECT TO CHANGE / ELIMINATION.

* Background Icon & formatting

* wip

* Review Changes. Remove unused Strings, clean up comments.

* Remove duplicate items used for testing

* Remove tvOS scrollIfLargerThanContainer for now.

* Edit Text-based Metadata

* ViewModel Cleanup

* use binding extensions

* Huge overhaul:

- Fix the notification when metadata was updated to work with 100% consistency
- Flip the locking to be true -> lock like server
- Redo the whole itemEditorViewModel to be more in-line with other viewModels | also fixes iPad weirdness
- Use itemViewModel for the edit view so I can just reuse those existing notifications instead of recreating the wheel
- More human dates for people - Date of death instead of "End date" (yikes)

* String fixes & overview size

* Fix build issues & String cleanup

* fix overview sizing, cleanup

* itemMetadataWasEdited -> temMetadataDidChange

* Creation of the NavigationBarMenuButtonModifier for an "ellipsis.circle" menu object in the toolbar. Makes it easier to ensure that this format looks the same throughout.

* Custom vs Official Rating + Menu Button Label change

* Menu button spacing and groundwork for other menu items (canDownload) since we already have the bool available. Currently disabled.

* Linting

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe Kribs 2024-11-30 23:56:49 -07:00 committed by GitHub
parent b9ac50c164
commit da40f6a3b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2652 additions and 643 deletions

View File

@ -80,8 +80,8 @@ final class ItemCoordinator: NavigationCoordinatable {
}
#if os(iOS)
func makeItemEditor(item: BaseItemDto) -> NavigationViewCoordinator<ItemEditorCoordinator> {
NavigationViewCoordinator(ItemEditorCoordinator(item: item))
func makeItemEditor(viewModel: ItemViewModel) -> NavigationViewCoordinator<ItemEditorCoordinator> {
NavigationViewCoordinator(ItemEditorCoordinator(viewModel: viewModel))
}
func makeDownloadTask(downloadTask: DownloadTask) -> NavigationViewCoordinator<DownloadTaskCoordinator> {

View File

@ -17,14 +17,23 @@ final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {
@Root
var start = makeStart
private let item: BaseItemDto
private let viewModel: ItemViewModel
init(item: BaseItemDto) {
self.item = item
@Route(.modal)
var editMetadata = makeEditMetadata
init(viewModel: ItemViewModel) {
self.viewModel = viewModel
}
func makeEditMetadata(item: BaseItemDto) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditMetadataView(viewModel: ItemEditorViewModel(item: item))
}
}
@ViewBuilder
func makeStart() -> some View {
ItemEditorView(item: item)
ItemEditorView(viewModel: viewModel)
}
}

View File

@ -42,3 +42,21 @@ extension Binding {
map(getter: { !$0 }, setter: { $0 })
}
}
extension Binding where Value: RangeReplaceableCollection, Value.Element: Equatable {
func contains(_ element: Value.Element) -> Binding<Bool> {
Binding<Bool>(
get: { wrappedValue.contains(element) },
set: { newValue in
if newValue {
if !wrappedValue.contains(element) {
wrappedValue.append(element)
}
} else {
wrappedValue.removeAll { $0 == element }
}
}
)
}
}

View File

@ -8,6 +8,8 @@
import SwiftUI
// TODO: break into separate files
struct HourMinuteFormatStyle: FormatStyle {
func format(_ value: TimeInterval) -> String {
@ -78,6 +80,29 @@ extension ParseableFormatStyle where Self == DayIntervalParseableFormatStyle {
}
}
struct NilIfEmptyStringFormatStyle: ParseableFormatStyle {
var parseStrategy: NilIfEmptyStringParseStrategy = .init()
func format(_ value: String?) -> String {
value ?? ""
}
}
struct NilIfEmptyStringParseStrategy: ParseStrategy {
func parse(_ value: String) -> String? {
value.isEmpty ? nil : value
}
}
extension ParseableFormatStyle where Self == NilIfEmptyStringFormatStyle {
static var nilIfEmptyString: NilIfEmptyStringFormatStyle {
.init()
}
}
extension FormatStyle where Self == TimeIntervalFormatStyle {
static func interval(

View File

@ -0,0 +1,35 @@
//
// 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
extension MetadataField: Displayable {
var displayTitle: String {
switch self {
case .cast:
return L10n.people
case .genres:
return L10n.genres
case .productionLocations:
return L10n.productionLocations
case .studios:
return L10n.studios
case .tags:
return L10n.tags
case .name:
return L10n.name
case .overview:
return L10n.overview
case .runtime:
return L10n.runtime
case .officialRating:
return L10n.officialRating
}
}
}

View File

@ -0,0 +1,27 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension Video3DFormat {
var displayTitle: String {
switch self {
case .halfSideBySide:
return L10n.halfSideBySide
case .fullSideBySide:
return L10n.fullSideBySide
case .fullTopAndBottom:
return L10n.fullTopAndBottom
case .halfTopAndBottom:
return L10n.halfTopAndBottom
case .mvc:
return L10n.mvc
}
}
}

View File

@ -0,0 +1,28 @@
//
// 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
enum BoxSetDisplayOrder: String, CaseIterable, Identifiable {
case dateModified = "DateModified"
case sortName = "SortName"
case premiereDate = "PremiereDate"
var id: String { rawValue }
var displayTitle: String {
switch self {
case .dateModified:
return L10n.dateModified
case .sortName:
return L10n.sortName
case .premiereDate:
return L10n.premiereDate
}
}
}

View File

@ -0,0 +1,52 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
enum SeriesDisplayOrder: String, CaseIterable, Identifiable {
case aired = "Aired"
case originalAirDate
case absolute
case dvd
case digital
case storyArc
case production
case tv
case alternate
case regional
case alternateDVD = "altdvd"
var id: String { rawValue }
var displayTitle: String {
switch self {
case .aired:
return L10n.aired
case .originalAirDate:
return L10n.originalAirDate
case .absolute:
return L10n.absolute
case .dvd:
return L10n.dvd
case .digital:
return L10n.digital
case .storyArc:
return L10n.storyArc
case .production:
return L10n.production
case .tv:
return L10n.tv
case .alternate:
return L10n.alternate
case .regional:
return L10n.regional
case .alternateDVD:
return L10n.alternateDVD
}
}
}

View File

@ -0,0 +1,26 @@
//
// 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
enum SeriesStatus: String, CaseIterable {
case continuing = "Continuing"
case ended = "Ended"
case unreleased = "Unreleased"
var displayTitle: String {
switch self {
case .continuing:
return L10n.continuing
case .ended:
return L10n.ended
case .unreleased:
return L10n.unreleased
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,195 @@
//
// 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

@ -169,7 +169,7 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
self.item = response.value
self.progress = 0.0
Notifications[.itemMetadataDidChange].post(object: itemId)
Notifications[.itemMetadataDidChange].post(object: item)
}
}
}

View File

@ -22,6 +22,7 @@ class ItemViewModel: ViewModel, Stateful {
case backgroundRefresh
case error(JellyfinAPIError)
case refresh
case replace(BaseItemDto)
case toggleIsFavorite
case toggleIsPlayed
}
@ -91,9 +92,7 @@ class ItemViewModel: ViewModel, Stateful {
// TODO: should replace with a more robust "PlaybackManager"
Notifications[.itemMetadataDidChange].publisher
.sink { [weak self] notification in
guard let userInfo = notification.object as? [String: String] else { return }
if let userInfo = notification.object as? [String: String] {
if let itemID = userInfo["itemID"], itemID == item.id {
Task { [weak self] in
await self?.send(.backgroundRefresh)
@ -103,6 +102,11 @@ class ItemViewModel: ViewModel, Stateful {
await self?.send(.backgroundRefresh)
}
}
} else if let newItem = notification.object as? BaseItemDto, newItem.id == self?.item.id {
Task { [weak self] in
await self?.send(.replace(newItem))
}
}
}
.store(in: &cancellables)
}
@ -195,6 +199,22 @@ class ItemViewModel: ViewModel, Stateful {
.asAnyCancellable()
return .refreshing
case let .replace(newItem):
backgroundStates.append(.refresh)
Task { [weak self] in
guard let self else { return }
do {
await MainActor.run {
self.backgroundStates.remove(.refresh)
self.item = newItem
}
}
}
.store(in: &cancellables)
return state
case .toggleIsFavorite:
toggleIsFavoriteTask?.cancel()

View File

@ -0,0 +1,81 @@
//
// 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
final class ParentalRatingsViewModel: ViewModel, Stateful {
// MARK: Action
enum Action: Equatable {
case refresh
}
// MARK: State
enum State: Hashable {
case content
case error(JellyfinAPIError)
case initial
case refreshing
}
@Published
private(set) var parentalRatings: [ParentalRating] = []
@Published
final var state: State = .initial
private var currentRefreshTask: AnyCancellable?
var hasNoResults: Bool {
parentalRatings.isEmpty
}
func respond(to action: Action) -> State {
switch action {
case .refresh:
currentRefreshTask?.cancel()
currentRefreshTask = Task { [weak self] in
guard let self else { return }
do {
let parentalRatings = try await getParentalRatings()
guard !Task.isCancelled else { return }
await MainActor.run {
self.parentalRatings = parentalRatings
self.state = .content
}
} catch {
guard !Task.isCancelled else { return }
await MainActor.run {
self.state = .error(.init(error.localizedDescription))
}
}
}
.asAnyCancellable()
return state
}
}
// MARK: - Fetch Parental Ratings
private func getParentalRatings() async throws -> [ParentalRating] {
let request = Paths.getParentalRatings
let response = try await userSession.client.send(request)
return response.value
}
}

View File

@ -79,6 +79,35 @@
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 */; };
4E6619FC2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */; };
4E6619FD2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */; };
4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A002CEFE39900025C99 /* EditMetadataView.swift */; };
4E661A0F2CEFE46300025C99 /* SeriesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A0D2CEFE46300025C99 /* SeriesSection.swift */; };
4E661A102CEFE46300025C99 /* TitleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A0E2CEFE46300025C99 /* TitleSection.swift */; };
4E661A112CEFE46300025C99 /* LockMetadataSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A082CEFE46300025C99 /* LockMetadataSection.swift */; };
4E661A122CEFE46300025C99 /* MediaFormatSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A092CEFE46300025C99 /* MediaFormatSection.swift */; };
4E661A132CEFE46300025C99 /* EpisodeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A062CEFE46300025C99 /* EpisodeSection.swift */; };
4E661A142CEFE46300025C99 /* DisplayOrderSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A052CEFE46300025C99 /* DisplayOrderSection.swift */; };
4E661A152CEFE46300025C99 /* LocalizationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A072CEFE46300025C99 /* LocalizationSection.swift */; };
4E661A162CEFE46300025C99 /* ParentialRatingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A0B2CEFE46300025C99 /* ParentialRatingsSection.swift */; };
4E661A172CEFE46300025C99 /* OverviewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A0A2CEFE46300025C99 /* OverviewSection.swift */; };
4E661A182CEFE46300025C99 /* ReviewsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A0C2CEFE46300025C99 /* ReviewsSection.swift */; };
4E661A192CEFE46300025C99 /* DateSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A042CEFE46300025C99 /* DateSection.swift */; };
4E661A1B2CEFE54800025C99 /* BoxSetDisplayOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A1A2CEFE53A00025C99 /* BoxSetDisplayOrder.swift */; };
4E661A1C2CEFE54800025C99 /* BoxSetDisplayOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A1A2CEFE53A00025C99 /* BoxSetDisplayOrder.swift */; };
4E661A1F2CEFE56E00025C99 /* SeriesDisplayOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A1E2CEFE56400025C99 /* SeriesDisplayOrder.swift */; };
4E661A202CEFE56E00025C99 /* SeriesDisplayOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A1E2CEFE56400025C99 /* SeriesDisplayOrder.swift */; };
4E661A222CEFE61000025C99 /* ParentalRatingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A212CEFE60C00025C99 /* ParentalRatingsViewModel.swift */; };
4E661A232CEFE61000025C99 /* ParentalRatingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A212CEFE60C00025C99 /* ParentalRatingsViewModel.swift */; };
4E661A252CEFE64500025C99 /* CountryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A242CEFE64200025C99 /* CountryPicker.swift */; };
4E661A272CEFE65000025C99 /* LanguagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A262CEFE64D00025C99 /* LanguagePicker.swift */; };
4E661A292CEFE68200025C99 /* Video3DFormatPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A282CEFE68100025C99 /* Video3DFormatPicker.swift */; };
4E661A2B2CEFE6F400025C99 /* Video3DFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A2A2CEFE6F300025C99 /* Video3DFormat.swift */; };
4E661A2C2CEFE6F400025C99 /* Video3DFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A2A2CEFE6F300025C99 /* Video3DFormat.swift */; };
4E661A2E2CEFE77700025C99 /* MetadataField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A2D2CEFE77700025C99 /* MetadataField.swift */; };
4E661A2F2CEFE77700025C99 /* MetadataField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A2D2CEFE77700025C99 /* MetadataField.swift */; };
4E661A312CEFE7BC00025C99 /* SeriesStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A302CEFE7B900025C99 /* SeriesStatus.swift */; };
4E661A322CEFE7BC00025C99 /* SeriesStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E661A302CEFE7B900025C99 /* SeriesStatus.swift */; };
4E699BB92CB33FC2007CBD5D /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E699BB82CB33FB5007CBD5D /* HomeSection.swift */; };
4E699BC02CB3477D007CBD5D /* HomeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E699BBF2CB34775007CBD5D /* HomeSection.swift */; };
4E6C27082C8BD0AD00FD2185 /* ActiveSessionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */; };
@ -149,6 +178,7 @@
4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87462CBF824B002354D2 /* DeviceRow.swift */; };
4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED87482CBF824B002354D2 /* DevicesView.swift */; };
4EED87512CBF84AD002354D2 /* DevicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */; };
4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEEEA232CFA8E1500527D79 /* NavigationBarMenuButton.swift */; };
4EF18B262CB9934C00343666 /* LibraryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B252CB9934700343666 /* LibraryRow.swift */; };
4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */; };
4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; };
@ -1146,6 +1176,28 @@
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>"; };
4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorViewModel.swift; sourceTree = "<group>"; };
4E661A002CEFE39900025C99 /* EditMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataView.swift; sourceTree = "<group>"; };
4E661A042CEFE46300025C99 /* DateSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateSection.swift; sourceTree = "<group>"; };
4E661A052CEFE46300025C99 /* DisplayOrderSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayOrderSection.swift; sourceTree = "<group>"; };
4E661A062CEFE46300025C99 /* EpisodeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeSection.swift; sourceTree = "<group>"; };
4E661A072CEFE46300025C99 /* LocalizationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationSection.swift; sourceTree = "<group>"; };
4E661A082CEFE46300025C99 /* LockMetadataSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockMetadataSection.swift; sourceTree = "<group>"; };
4E661A092CEFE46300025C99 /* MediaFormatSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFormatSection.swift; sourceTree = "<group>"; };
4E661A0A2CEFE46300025C99 /* OverviewSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSection.swift; sourceTree = "<group>"; };
4E661A0B2CEFE46300025C99 /* ParentialRatingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentialRatingsSection.swift; sourceTree = "<group>"; };
4E661A0C2CEFE46300025C99 /* ReviewsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsSection.swift; sourceTree = "<group>"; };
4E661A0D2CEFE46300025C99 /* SeriesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesSection.swift; sourceTree = "<group>"; };
4E661A0E2CEFE46300025C99 /* TitleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleSection.swift; sourceTree = "<group>"; };
4E661A1A2CEFE53A00025C99 /* BoxSetDisplayOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoxSetDisplayOrder.swift; sourceTree = "<group>"; };
4E661A1E2CEFE56400025C99 /* SeriesDisplayOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesDisplayOrder.swift; sourceTree = "<group>"; };
4E661A212CEFE60C00025C99 /* ParentalRatingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentalRatingsViewModel.swift; sourceTree = "<group>"; };
4E661A242CEFE64200025C99 /* CountryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryPicker.swift; sourceTree = "<group>"; };
4E661A262CEFE64D00025C99 /* LanguagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguagePicker.swift; sourceTree = "<group>"; };
4E661A282CEFE68100025C99 /* Video3DFormatPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video3DFormatPicker.swift; sourceTree = "<group>"; };
4E661A2A2CEFE6F300025C99 /* Video3DFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Video3DFormat.swift; sourceTree = "<group>"; };
4E661A2D2CEFE77700025C99 /* MetadataField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataField.swift; sourceTree = "<group>"; };
4E661A302CEFE7B900025C99 /* SeriesStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesStatus.swift; sourceTree = "<group>"; };
4E699BB82CB33FB5007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = "<group>"; };
4E699BBF2CB34775007CBD5D /* HomeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSection.swift; sourceTree = "<group>"; };
4E6C27072C8BD0AD00FD2185 /* ActiveSessionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionDetailView.swift; sourceTree = "<group>"; };
@ -1205,6 +1257,7 @@
4EED87462CBF824B002354D2 /* DeviceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRow.swift; sourceTree = "<group>"; };
4EED87482CBF824B002354D2 /* DevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesView.swift; sourceTree = "<group>"; };
4EED874F2CBF84AD002354D2 /* DevicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesViewModel.swift; sourceTree = "<group>"; };
4EEEEA232CFA8E1500527D79 /* NavigationBarMenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarMenuButton.swift; sourceTree = "<group>"; };
4EF18B252CB9934700343666 /* LibraryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = "<group>"; };
4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListColumnsPickerView.swift; sourceTree = "<group>"; };
4EF18B292CB993AD00343666 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = "<group>"; };
@ -2123,6 +2176,50 @@
path = AdminDashboardView;
sourceTree = "<group>";
};
4E6619FF2CEFE39000025C99 /* EditMetadataView */ = {
isa = PBXGroup;
children = (
4E661A022CEFE42200025C99 /* Components */,
4E661A002CEFE39900025C99 /* EditMetadataView.swift */,
);
path = EditMetadataView;
sourceTree = "<group>";
};
4E661A022CEFE42200025C99 /* Components */ = {
isa = PBXGroup;
children = (
4E661A032CEFE42800025C99 /* Sections */,
);
path = Components;
sourceTree = "<group>";
};
4E661A032CEFE42800025C99 /* Sections */ = {
isa = PBXGroup;
children = (
4E661A042CEFE46300025C99 /* DateSection.swift */,
4E661A052CEFE46300025C99 /* DisplayOrderSection.swift */,
4E661A062CEFE46300025C99 /* EpisodeSection.swift */,
4E661A072CEFE46300025C99 /* LocalizationSection.swift */,
4E661A082CEFE46300025C99 /* LockMetadataSection.swift */,
4E661A092CEFE46300025C99 /* MediaFormatSection.swift */,
4E661A0A2CEFE46300025C99 /* OverviewSection.swift */,
4E661A0B2CEFE46300025C99 /* ParentialRatingsSection.swift */,
4E661A0C2CEFE46300025C99 /* ReviewsSection.swift */,
4E661A0D2CEFE46300025C99 /* SeriesSection.swift */,
4E661A0E2CEFE46300025C99 /* TitleSection.swift */,
);
path = Sections;
sourceTree = "<group>";
};
4E661A1D2CEFE55200025C99 /* DisplayOrder */ = {
isa = PBXGroup;
children = (
4E661A1A2CEFE53A00025C99 /* BoxSetDisplayOrder.swift */,
4E661A1E2CEFE56400025C99 /* SeriesDisplayOrder.swift */,
);
path = DisplayOrder;
sourceTree = "<group>";
};
4E699BB52CB33F4B007CBD5D /* CustomizeViewsSettings */ = {
isa = PBXGroup;
children = (
@ -2188,6 +2285,7 @@
isa = PBXGroup;
children = (
4E8F74A62CE03D4C00CC8969 /* Components */,
4E6619FF2CEFE39000025C99 /* EditMetadataView */,
4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */,
);
path = ItemEditorView;
@ -2205,6 +2303,7 @@
isa = PBXGroup;
children = (
4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */,
4E6619FB2CEFE2B500025C99 /* ItemEditorViewModel.swift */,
4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */,
);
path = ItemEditorViewModel;
@ -2456,6 +2555,7 @@
E1EDA8D52B924CA500F9A57E /* LibraryViewModel */,
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */,
E1CAF65C2BA345830087D991 /* MediaViewModel */,
4E661A212CEFE60C00025C99 /* ParentalRatingsViewModel.swift */,
E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */,
6334175C287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift */,
E1BCDB4E2BE1F491009F6744 /* ResetUserPasswordViewModel.swift */,
@ -2564,6 +2664,7 @@
E1F5CF042CB09EA000607465 /* CurrentDate.swift */,
4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */,
E17FB55128C119D400311DFE /* Displayable.swift */,
4E661A1D2CEFE55200025C99 /* DisplayOrder */,
E1579EA62B97DC1500A31CA1 /* Eventful.swift */,
E1092F4B29106F9F00163F57 /* GestureAction.swift */,
E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */,
@ -2584,6 +2685,7 @@
E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */,
E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.swift */,
E164A7F52BE4814700A54B18 /* SelectUserServerSelection.swift */,
4E661A302CEFE7B900025C99 /* SeriesStatus.swift */,
E129429228F2845000796AC6 /* SliderType.swift */,
E11042742B8013DF00821020 /* Stateful.swift */,
E149CCAC2BE6ECC8008B9331 /* Storable.swift */,
@ -2842,6 +2944,7 @@
children = (
E1D8429429346C6400D1041A /* BasicStepper.swift */,
E133328C2953AE4B00EE76AB /* CircularProgressView.swift */,
4E661A242CEFE64200025C99 /* CountryPicker.swift */,
E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */,
E18E01A7288746AF0022598C /* DotHStack.swift */,
E1DE2B492B97ECB900F6715F /* ErrorView.swift */,
@ -2849,6 +2952,7 @@
E178B0752BE435D70023651B /* HourMinutePicker.swift */,
E1DC7AC92C63337C00AEE368 /* iOS15View.swift */,
E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */,
4E661A262CEFE64D00025C99 /* LanguagePicker.swift */,
4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */,
4E16FD4E2C0183B500110147 /* LetterPickerBar */,
E1A8FDEB2C0574A800D0A51C /* ListRow.swift */,
@ -2866,6 +2970,7 @@
E1581E26291EF59800D6C640 /* SplitContentView.swift */,
E1D27EE62BBC955F00152D16 /* UnmaskSecureField.swift */,
E157562F29355B7900976E1F /* UpdateView.swift */,
4E661A282CEFE68100025C99 /* Video3DFormatPicker.swift */,
);
path = Components;
sourceTree = "<group>";
@ -3979,6 +4084,7 @@
4E49DECE2CE54D2700352DCD /* MaxBitratePolicy.swift */,
E1F5F9B12BA0200500BA5014 /* MediaSourceInfo */,
E122A9122788EAAD0060FA63 /* MediaStream.swift */,
4E661A2D2CEFE77700025C99 /* MetadataField.swift */,
E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */,
E1ED7FDA2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift */,
4E2182E42CAF67EF0094806B /* PlayMethod.swift */,
@ -3994,6 +4100,7 @@
4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */,
E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */,
E18CE0B128A229E70092E7F1 /* UserDto.swift */,
4E661A2A2CEFE6F300025C99 /* Video3DFormat.swift */,
);
path = JellyfinAPI;
sourceTree = "<group>";
@ -4031,6 +4138,7 @@
children = (
E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */,
E1194F4D2BEABA9100888DB6 /* NavigationBarCloseButton.swift */,
4EEEEA232CFA8E1500527D79 /* NavigationBarMenuButton.swift */,
E113133028BDB6D600930F75 /* NavigationBarDrawerButtons */,
E11895B12893842D0042947B /* NavigationBarOffset */,
);
@ -4700,6 +4808,7 @@
E1DC983E296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */,
4E2AC4BF2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */,
4EBE06472C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */,
4E661A2B2CEFE6F400025C99 /* Video3DFormat.swift in Sources */,
E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */,
53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */,
E1575E99293E7B1E001665B1 /* UIColor.swift in Sources */,
@ -4730,6 +4839,7 @@
E13DD3FA2717E961009D4DAF /* SelectUserViewModel.swift in Sources */,
C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */,
E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */,
4E6619FC2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */,
E1CB757F2C80F28F00217C76 /* SubtitleProfile.swift in Sources */,
E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */,
E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */,
@ -4743,6 +4853,7 @@
E1763A292BF3046A004DF6AB /* AddUserButton.swift in Sources */,
E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */,
E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */,
4E661A2F2CEFE77700025C99 /* MetadataField.swift in Sources */,
E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */,
E14E9DF22BCF7A99004E3371 /* ItemLetter.swift in Sources */,
E10B1EC82BD9AF6100A92EAF /* V2ServerModel.swift in Sources */,
@ -4786,6 +4897,7 @@
E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */,
E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */,
E1ED7FDB2CAA4B6D00ACB6E3 /* PlayerStateInfo.swift in Sources */,
4E661A222CEFE61000025C99 /* ParentalRatingsViewModel.swift in Sources */,
E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */,
E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */,
E19D41A82BEEDC5F0082B8B2 /* UserLocalSecurityViewModel.swift in Sources */,
@ -4883,6 +4995,7 @@
E1575E75293E77B5001665B1 /* LibraryDisplayType.swift in Sources */,
E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */,
E193D5502719430400900D82 /* ServerDetailView.swift in Sources */,
4E661A312CEFE7BC00025C99 /* SeriesStatus.swift in Sources */,
E12E30F1296383810022FAC9 /* SplitFormWindowView.swift in Sources */,
E1356E0429A731EB00382563 /* SeparatorHStack.swift in Sources */,
E1575E69293E77B5001665B1 /* ItemSortBy.swift in Sources */,
@ -4948,6 +5061,7 @@
E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */,
4E2AC4CC2C6C494E00DD600D /* VideoCodec.swift in Sources */,
4EF18B262CB9934C00343666 /* LibraryRow.swift in Sources */,
4E661A1B2CEFE54800025C99 /* BoxSetDisplayOrder.swift in Sources */,
E129428628F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */,
53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */,
62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
@ -4955,6 +5069,7 @@
C46DD8D92A8DC2990046A504 /* LiveNativeVideoPlayer.swift in Sources */,
E1575E9F293E7B1E001665B1 /* Int.swift in Sources */,
E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */,
4E661A1F2CEFE56E00025C99 /* SeriesDisplayOrder.swift in Sources */,
E145EB462BE0AD4E003BF6F3 /* Set.swift in Sources */,
E1575E7D293E77B5001665B1 /* PosterDisplayType.swift in Sources */,
E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */,
@ -5104,6 +5219,7 @@
4EC2B1A22CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */,
E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */,
E18E01DB288747230022598C /* iPadOSEpisodeItemView.swift in Sources */,
4E661A292CEFE68200025C99 /* Video3DFormatPicker.swift in Sources */,
E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */,
621338932660107500A81A2A /* String.swift in Sources */,
E17AC96F2954EE4B003D2BC2 /* DownloadListViewModel.swift in Sources */,
@ -5125,6 +5241,7 @@
62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */,
E1A1528A28FD22F600600579 /* TextPairView.swift in Sources */,
E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */,
4E661A232CEFE61000025C99 /* ParentalRatingsViewModel.swift in Sources */,
E170D0E2294CC8000017224C /* VideoPlayer+Actions.swift in Sources */,
4E10C8112CC030CD0012CC9F /* DeviceDetailsView.swift in Sources */,
E148128828C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */,
@ -5176,6 +5293,17 @@
E15D63ED2BD622A700AA665D /* CompactChannelView.swift in Sources */,
E18A8E8528D60D0000333B9A /* VideoPlayerCoordinator.swift in Sources */,
E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */,
4E661A0F2CEFE46300025C99 /* SeriesSection.swift in Sources */,
4E661A102CEFE46300025C99 /* TitleSection.swift in Sources */,
4E661A112CEFE46300025C99 /* LockMetadataSection.swift in Sources */,
4E661A122CEFE46300025C99 /* MediaFormatSection.swift in Sources */,
4E661A132CEFE46300025C99 /* EpisodeSection.swift in Sources */,
4E661A142CEFE46300025C99 /* DisplayOrderSection.swift in Sources */,
4E661A152CEFE46300025C99 /* LocalizationSection.swift in Sources */,
4E661A162CEFE46300025C99 /* ParentialRatingsSection.swift in Sources */,
4E661A172CEFE46300025C99 /* OverviewSection.swift in Sources */,
4E661A182CEFE46300025C99 /* ReviewsSection.swift in Sources */,
4E661A192CEFE46300025C99 /* DateSection.swift in Sources */,
E1A3E4CD2BB7D8C8005C59F8 /* Label-iOS.swift in Sources */,
E13DD3EC27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */,
@ -5184,8 +5312,10 @@
4EB538C52CE3E25700EB72D5 /* ExternalAccessSection.swift in Sources */,
625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */,
E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */,
4E661A1C2CEFE54800025C99 /* BoxSetDisplayOrder.swift in Sources */,
E133328829538D8D00EE76AB /* Files.swift in Sources */,
C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */,
4E661A322CEFE7BC00025C99 /* SeriesStatus.swift in Sources */,
E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */,
BD39577E2C1140810078CEF8 /* TransitionSection.swift in Sources */,
4EC2B1A52CC96FA400D866BE /* ServerUserAdminViewModel.swift in Sources */,
@ -5205,6 +5335,7 @@
E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
4E2AC4C82C6C493C00DD600D /* SubtitleFormat.swift in Sources */,
E19D41B02BF2B7540082B8B2 /* URLSessionConfiguration.swift in Sources */,
4E661A2E2CEFE77700025C99 /* MetadataField.swift in Sources */,
E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */,
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
@ -5319,6 +5450,7 @@
E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */,
4E36395C2CC4DF0E00110EBC /* APIKeysViewModel.swift in Sources */,
E168BD10289A4162001A6922 /* HomeView.swift in Sources */,
4EEEEA242CFA8E1500527D79 /* NavigationBarMenuButton.swift in Sources */,
4EC2B1A92CC97C0700D866BE /* ServerUserDetailsView.swift in Sources */,
E11562952C818CB2001D5DE4 /* BindingBox.swift in Sources */,
4E16FD532C01840C00110147 /* LetterPickerBar.swift in Sources */,
@ -5333,6 +5465,7 @@
E15D63EF2BD6DFC200AA665D /* SystemImageable.swift in Sources */,
E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */,
E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */,
4E661A012CEFE39D00025C99 /* EditMetadataView.swift in Sources */,
C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */,
E18E01AD288746AF0022598C /* DotHStack.swift in Sources */,
E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */,
@ -5350,6 +5483,7 @@
E152107C2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */,
E17AC9732955007A003D2BC2 /* DownloadTaskButton.swift in Sources */,
E145EB4F2BE168AC003BF6F3 /* SwiftfinStore+ServerState.swift in Sources */,
4E661A2C2CEFE6F400025C99 /* Video3DFormat.swift in Sources */,
E1A1528228FD126C00600579 /* VerticalAlignment.swift in Sources */,
E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */,
E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */,
@ -5425,10 +5559,12 @@
E11B1B6C2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
4E182C9C2C94993200FBEFD5 /* ServerTasksView.swift in Sources */,
E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */,
4E6619FD2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */,
E1BDF2EF29522A5900CC0294 /* AudioActionButton.swift in Sources */,
E174120F29AE9D94003EF3B5 /* NavigationCoordinatable.swift in Sources */,
E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */,
E1DC9844296DECB600982F06 /* ProgressIndicator.swift in Sources */,
4E661A202CEFE56E00025C99 /* SeriesDisplayOrder.swift in Sources */,
6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */,
4E49DECD2CE54C7A00352DCD /* PermissionSection.swift in Sources */,
E10B1ECA2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */,
@ -5439,6 +5575,7 @@
4E026A8B2CE804E7005471B5 /* ResetUserPasswordView.swift in Sources */,
62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */,
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */,
4E661A252CEFE64500025C99 /* CountryPicker.swift in Sources */,
E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */,
4E10C8192CC045700012CC9F /* CustomDeviceNameSection.swift in Sources */,
4EB538BD2CE3CCD100EB72D5 /* MediaPlaybackSection.swift in Sources */,
@ -5561,6 +5698,7 @@
E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */,
E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */,
E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */,
4E661A272CEFE65000025C99 /* LanguagePicker.swift in Sources */,
62E1DCC3273CE19800C9AE76 /* URL.swift in Sources */,
E11BDF7A2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */,
E170D0E4294CC8AB0017224C /* VideoPlayer+KeyCommands.swift in Sources */,

View File

@ -0,0 +1,47 @@
//
// 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 SwiftUI
struct CountryPicker: View {
let title: String
@Binding
var selectedCountryCode: String?
// MARK: - Get all localized countries
private var countries: [(code: String?, name: String)] {
var uniqueCountries = Set<String>()
var countryList: [(code: String?, name: String)] = Locale.isoRegionCodes.compactMap { code in
let locale = Locale.current
if let name = locale.localizedString(forRegionCode: code),
!uniqueCountries.contains(code)
{
uniqueCountries.insert(code)
return (code, name)
}
return nil
}
.sorted { $0.name < $1.name }
// Add None as an option at the top of the list
countryList.insert((code: nil, name: L10n.none), at: 0)
return countryList
}
// MARK: - Body
var body: some View {
Picker(title, selection: $selectedCountryCode) {
ForEach(countries, id: \.code) { country in
Text(country.name).tag(country.code)
}
}
}
}

View File

@ -0,0 +1,48 @@
//
// 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 SwiftUI
struct LanguagePicker: View {
let title: String
@Binding
var selectedLanguageCode: String?
// MARK: - Get all localized languages
private var languages: [(code: String?, name: String)] {
var uniqueLanguages = Set<String>()
var languageList: [(code: String?, name: String)] = Locale.availableIdentifiers.compactMap { identifier in
let locale = Locale(identifier: identifier)
if let code = locale.languageCode,
let name = locale.localizedString(forLanguageCode: code),
!uniqueLanguages.contains(code)
{
uniqueLanguages.insert(code)
return (code, name)
}
return nil
}
.sorted { $0.name < $1.name }
// Add None as an option at the top of the list
languageList.insert((code: nil, name: L10n.none), at: 0)
return languageList
}
// MARK: - Body
var body: some View {
Picker(title, selection: $selectedLanguageCode) {
ForEach(languages, id: \.code) { language in
Text(language.name).tag(language.code)
}
}
}
}

View File

@ -0,0 +1,25 @@
//
// 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
struct Video3DFormatPicker: View {
let title: String
@Binding
var selectedFormat: Video3DFormat?
var body: some View {
Picker(title, selection: $selectedFormat) {
Text(L10n.none).tag(nil as Video3DFormat?)
ForEach(Video3DFormat.allCases, id: \.self) { format in
Text(format.displayTitle).tag(format as Video3DFormat?)
}
}
}
}

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 Defaults
import SwiftUI
struct NavigationBarMenuButtonModifier<Content: View>: ViewModifier {
@Default(.accentColor)
private var accentColor
let isLoading: Bool
let isHidden: Bool
let items: () -> Content
func body(content: Self.Content) -> some View {
content.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
if isLoading {
ProgressView()
}
if !isHidden {
Menu(L10n.options, systemImage: "ellipsis.circle") {
items()
}
.labelStyle(.iconOnly)
.backport
.fontWeight(.semibold)
.foregroundStyle(accentColor)
}
}
}
}
}

View File

@ -79,6 +79,22 @@ extension View {
)
}
@ViewBuilder
func navigationBarMenuButton<Content: View>(
isLoading: Bool = false,
isHidden: Bool = false,
@ViewBuilder
_ items: @escaping () -> Content
) -> some View {
modifier(
NavigationBarMenuButtonModifier(
isLoading: isLoading,
isHidden: isHidden,
items: items
)
)
}
@ViewBuilder
func listRowCornerRadius(_ radius: CGFloat) -> some View {
if #unavailable(iOS 16) {

View File

@ -61,8 +61,20 @@ struct ServerUsersView: View {
navigationBarSelectView
}
}
ToolbarItemGroup(placement: .topBarTrailing) {
navigationBarEditView
ToolbarItem(placement: .topBarTrailing) {
if isEditing {
Button(isEditing ? L10n.cancel : L10n.edit) {
isEditing.toggle()
UIDevice.impact(.light)
if !isEditing {
selectedUsers.removeAll()
}
}
.buttonStyle(.toolbarPill)
.foregroundStyle(accentColor)
}
}
ToolbarItem(placement: .bottomBar) {
if isEditing {
@ -75,6 +87,28 @@ struct ServerUsersView: View {
}
}
}
.navigationBarMenuButton(
isLoading: viewModel.backgroundStates.contains(.gettingUsers),
isHidden: isEditing
) {
Button(L10n.addUser, systemImage: "plus") {
router.route(to: \.addServerUser)
}
if viewModel.users.isNotEmpty {
Button(L10n.editUsers, systemImage: "checkmark.circle") {
isEditing = true
}
}
Divider()
Section(L10n.filters) {
Toggle(L10n.hidden, systemImage: "eye.slash", isOn: $isHiddenFilterActive)
Toggle(L10n.disabled, systemImage: "person.slash", isOn: $isDisabledFilterActive)
}
}
.onChange(of: isDisabledFilterActive) { newValue in
viewModel.send(.getUsers(
isHidden: isHiddenFilterActive,
@ -175,51 +209,6 @@ struct ServerUsersView: View {
}
}
// MARK: - Navigation Bar Edit Content
@ViewBuilder
private var navigationBarEditView: some View {
if viewModel.backgroundStates.contains(.gettingUsers) {
ProgressView()
}
if isEditing {
Button(isEditing ? L10n.cancel : L10n.edit) {
isEditing.toggle()
UIDevice.impact(.light)
if !isEditing {
selectedUsers.removeAll()
}
}
.buttonStyle(.toolbarPill)
.foregroundStyle(accentColor)
} else {
Menu(L10n.options, systemImage: "ellipsis.circle") {
Button(L10n.addUser, systemImage: "plus") {
router.route(to: \.addServerUser)
}
if viewModel.users.isNotEmpty {
Button(L10n.editUsers, systemImage: "checkmark.circle") {
isEditing = true
}
}
Divider()
Section(L10n.filters) {
Toggle(L10n.hidden, systemImage: "eye.slash", isOn: $isHiddenFilterActive)
Toggle(L10n.disabled, systemImage: "person.slash", isOn: $isDisabledFilterActive)
}
}
.labelStyle(.iconOnly)
.backport
.fontWeight(.semibold)
}
}
// MARK: - Navigation Bar Select/Remove All Content
@ViewBuilder

View File

@ -0,0 +1,55 @@
//
// 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 JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct DateSection: View {
@Binding
var item: BaseItemDto
let itemType: BaseItemKind
var body: some View {
Section(L10n.dates) {
DatePicker(
L10n.dateAdded,
selection: $item.dateCreated.coalesce(.now),
displayedComponents: .date
)
DatePicker(
itemType == .person ? L10n.birthday : L10n.releaseDate,
selection: $item.premiereDate.coalesce(.now),
displayedComponents: .date
)
if itemType == .series || itemType == .person {
DatePicker(
itemType == .person ? L10n.dateOfDeath : L10n.endDate,
selection: $item.endDate.coalesce(.now),
displayedComponents: .date
)
}
}
Section(L10n.year) {
TextField(
itemType == .person ? L10n.birthYear : L10n.year,
value: $item.productionYear,
format: .number.grouping(.never)
)
.keyboardType(.numberPad)
}
}
}
}

View File

@ -0,0 +1,61 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Combine
import JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct DisplayOrderSection: View {
@Binding
var item: BaseItemDto
let itemType: BaseItemKind
var body: some View {
Section(L10n.displayOrder) {
switch itemType {
case .boxSet:
Picker(
L10n.displayOrder,
selection: $item.displayOrder
.coalesce("")
.map(
getter: { BoxSetDisplayOrder(rawValue: $0) ?? .dateModified },
setter: { $0.rawValue }
)
) {
ForEach(BoxSetDisplayOrder.allCases) { order in
Text(order.displayTitle).tag(order)
}
}
case .series:
Picker(
L10n.displayOrder,
selection: $item.displayOrder
.coalesce("")
.map(
getter: { SeriesDisplayOrder(rawValue: $0) ?? .aired },
setter: { $0.rawValue }
)
) {
ForEach(SeriesDisplayOrder.allCases) { order in
Text(order.displayTitle).tag(order)
}
}
default:
EmptyView()
}
}
}
}
}

View File

@ -0,0 +1,55 @@
//
// 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 JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct EpisodeSection: View {
@Binding
var item: BaseItemDto
var body: some View {
Section(L10n.season) {
// MARK: Season Number
ChevronAlertButton(
L10n.season,
subtitle: item.parentIndexNumber?.description,
description: L10n.enterSeasonNumber
) {
TextField(
L10n.season,
value: $item.parentIndexNumber,
format: .number
)
.keyboardType(.numberPad)
}
// MARK: Episode Number
ChevronAlertButton(
L10n.episode,
subtitle: item.indexNumber?.description,
description: L10n.enterEpisodeNumber
) {
TextField(
L10n.episode,
value: $item.indexNumber,
format: .number
)
.keyboardType(.numberPad)
}
}
}
}
}

View File

@ -0,0 +1,34 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Combine
import JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct LocalizationSection: View {
@Binding
var item: BaseItemDto
var body: some View {
Section(L10n.metadataPreferences) {
LanguagePicker(
title: L10n.language,
selectedLanguageCode: $item.preferredMetadataLanguage
)
CountryPicker(
title: L10n.country,
selectedCountryCode: $item.preferredMetadataCountryCode
)
}
}
}
}

View File

@ -0,0 +1,43 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Combine
import JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct LockMetadataSection: View {
@Binding
var item: BaseItemDto
// TODO: Animation when lockAllFields is selected
var body: some View {
Section(L10n.lockedFields) {
Toggle(
L10n.lockAllFields,
isOn: $item.lockData.coalesce(false)
)
}
if item.lockData != true {
Section {
ForEach(MetadataField.allCases, id: \.self) { field in
Toggle(
field.displayTitle,
isOn: $item.lockedFields
.coalesce([])
.contains(field)
)
}
}
}
}
}
}

View File

@ -0,0 +1,35 @@
//
// 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 JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct MediaFormatSection: View {
@Binding
var item: BaseItemDto
var body: some View {
Section(L10n.format) {
TextField(
L10n.originalAspectRatio,
value: $item.aspectRatio,
format: .nilIfEmptyString
)
Video3DFormatPicker(
title: L10n.format3D,
selectedFormat: $item.video3DFormat
)
}
}
}
}

View File

@ -0,0 +1,58 @@
//
// 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 JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct OverviewSection: View {
@Binding
var item: BaseItemDto
let itemType: BaseItemKind
private var showTaglines: Bool {
[
BaseItemKind.movie,
.series,
.audioBook,
.book,
.audio,
].contains(itemType)
}
var body: some View {
if showTaglines {
// There doesn't seem to be a usage anywhere of more than 1 tagline?
Section(L10n.taglines) {
TextField(
L10n.tagline,
value: $item.taglines
.map(
getter: { $0 == nil ? "" : $0!.first },
setter: { $0 == nil ? [] : [$0!] }
),
format: .nilIfEmptyString
)
}
}
Section(L10n.overview) {
TextEditor(text: $item.overview.coalesce(""))
.onAppear {
// Workaround for iOS 17 and earlier bug
// where the row height won't be set properly
item.overview = item.overview
}
}
}
}
}

View File

@ -0,0 +1,105 @@
//
// 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 JellyfinAPI
import SwiftUI
// TODO: Reimagine this whole thing to be much leaner.
extension EditMetadataView {
struct ParentalRatingSection: View {
@Binding
var item: BaseItemDto
@ObservedObject
private var viewModel = ParentalRatingsViewModel()
@State
private var officialRatings: [ParentalRating] = []
@State
private var customRatings: [ParentalRating] = []
// MARK: - Body
var body: some View {
Section(L10n.parentalRating) {
// MARK: Official Rating Picker
Picker(
L10n.officialRating,
selection: $item.officialRating
.map(
getter: { value in officialRatings.first { $0.name == value } },
setter: { $0?.name }
)
) {
Text(L10n.none).tag(nil as ParentalRating?)
ForEach(officialRatings, id: \.self) { rating in
Text(rating.name ?? "").tag(rating as ParentalRating?)
}
}
.onAppear {
updateOfficialRatings()
}
.onChange(of: viewModel.parentalRatings) { _ in
updateOfficialRatings()
}
// MARK: Custom Rating Picker
Picker(
L10n.customRating,
selection: $item.customRating
.map(
getter: { value in customRatings.first { $0.name == value } },
setter: { $0?.name }
)
) {
Text(L10n.none).tag(nil as ParentalRating?)
ForEach(customRatings, id: \.self) { rating in
Text(rating.name ?? "").tag(rating as ParentalRating?)
}
}
.onAppear {
updateCustomRatings()
}
.onChange(of: viewModel.parentalRatings) { _ in
updateCustomRatings()
}
}
.onFirstAppear {
viewModel.send(.refresh)
}
}
// MARK: - Update Official Rating
private func updateOfficialRatings() {
officialRatings = viewModel.parentalRatings
if let currentRatingName = item.officialRating,
!officialRatings.contains(where: { $0.name == currentRatingName })
{
officialRatings.append(ParentalRating(name: currentRatingName))
}
}
// MARK: - Update Custom Rating
private func updateCustomRatings() {
customRatings = viewModel.parentalRatings
if let currentRatingName = item.customRating,
!customRatings.contains(where: { $0.name == currentRatingName })
{
customRatings.append(ParentalRating(name: currentRatingName))
}
}
}
}

View File

@ -0,0 +1,65 @@
//
// 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 JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct ReviewsSection: View {
@Binding
var item: BaseItemDto
var body: some View {
Section(L10n.reviews) {
// MARK: Critics Rating
ChevronAlertButton(
L10n.critics,
subtitle: item.criticRating.map { "\($0)" } ?? .emptyDash,
description: L10n.ratingDescription(L10n.critics)
) {
TextField(
L10n.rating,
value: $item.criticRating,
format: .number.precision(.fractionLength(1))
)
.keyboardType(.decimalPad)
.onChange(of: item.criticRating) { _ in
if let rating = item.criticRating {
item.criticRating = min(max(rating, 0), 10)
}
}
}
// MARK: Community Rating
ChevronAlertButton(
L10n.community,
subtitle: item.communityRating.map { "\($0)" } ?? .emptyDash,
description: L10n.ratingDescription(L10n.community)
) {
TextField(
L10n.rating,
value: $item.communityRating,
format: .number.precision(.fractionLength(1))
)
.keyboardType(.decimalPad)
.onChange(of: item.communityRating) { _ in
if let rating = item.communityRating {
item.communityRating = min(max(rating, 0), 10)
}
}
}
}
}
}
}

View File

@ -0,0 +1,147 @@
//
// 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 JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct SeriesSection: View {
@Binding
private var item: BaseItemDto
@State
private var tempRunTime: Int?
// MARK: - Initializer
init(item: Binding<BaseItemDto>) {
self._item = item
self.tempRunTime = Int(ServerTicks(item.wrappedValue.runTimeTicks ?? 0).minutes)
}
// MARK: - Body
var body: some View {
Section(L10n.series) {
seriesStatusView
}
Section(L10n.episodes) {
airTimeView
runTimeView
}
Section(L10n.dayOfWeek) {
airDaysView
}
}
// MARK: - Series Status View
@ViewBuilder
private var seriesStatusView: some View {
Picker(
L10n.status,
selection: $item.status
.coalesce("")
.map(
getter: { SeriesStatus(rawValue: $0) ?? .continuing },
setter: { $0.rawValue }
)
) {
ForEach(SeriesStatus.allCases, id: \.self) { status in
Text(status.displayTitle).tag(status)
}
}
}
// MARK: - Air Time View
@ViewBuilder
private var airTimeView: some View {
DatePicker(
L10n.airTime,
selection: $item.airTime
.coalesce("00:00")
.map(
getter: { parseAirTimeToDate($0) },
setter: { formatDateToString($0) }
),
displayedComponents: .hourAndMinute
)
}
// MARK: - Air Days View
@ViewBuilder
private var airDaysView: some View {
ForEach(DayOfWeek.allCases, id: \.self) { field in
Toggle(
field.displayTitle ?? L10n.unknown,
isOn: $item.airDays
.coalesce([])
.contains(field)
)
}
}
// MARK: - Run Time View
@ViewBuilder
private var runTimeView: some View {
ChevronAlertButton(
L10n.runTime,
subtitle: ServerTicks(item.runTimeTicks ?? 0)
.seconds.formatted(.hourMinute),
description: L10n.episodeRuntimeDescription
) {
TextField(
L10n.minutes,
value: $tempRunTime
.coalesce(0)
.min(0),
format: .number
)
.keyboardType(.numberPad)
} onSave: {
if let tempRunTime, tempRunTime != 0 {
item.runTimeTicks = ServerTicks(minutes: tempRunTime).ticks
} else {
item.runTimeTicks = nil
}
} onCancel: {
if let originalRunTime = item.runTimeTicks {
tempRunTime = Int(ServerTicks(originalRunTime).minutes)
} else {
tempRunTime = nil
}
}
}
// MARK: - Parse AirTime to Date
private func parseAirTimeToDate(_ airTime: String) -> Date {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "HH:mm"
return dateFormatter.date(from: airTime) ?? Date()
}
// MARK: - Format Date to String
private func formatDateToString(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "HH:mm"
return dateFormatter.string(from: date)
}
}
}

View File

@ -0,0 +1,46 @@
//
// 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 JellyfinAPI
import SwiftUI
extension EditMetadataView {
struct TitleSection: View {
@Binding
var item: BaseItemDto
var body: some View {
Section(L10n.title) {
TextField(
L10n.title,
value: $item.name,
format: .nilIfEmptyString
)
}
Section(L10n.originalTitle) {
TextField(
L10n.originalTitle,
value: $item.originalTitle,
format: .nilIfEmptyString
)
}
Section(L10n.sortTitle) {
TextField(
L10n.sortTitle,
value: $item.forcedSortName,
format: .nilIfEmptyString
)
}
}
}
}

View File

@ -0,0 +1,95 @@
//
// 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 JellyfinAPI
import SwiftUI
struct EditMetadataView: View {
@EnvironmentObject
private var router: BasicNavigationViewCoordinator.Router
@ObservedObject
private var viewModel: ItemEditorViewModel<BaseItemDto>
@Binding
var item: BaseItemDto
@State
private var tempItem: BaseItemDto
private let itemType: BaseItemKind
// MARK: - Initializer
init(viewModel: ItemEditorViewModel<BaseItemDto>) {
self.viewModel = viewModel
self._item = Binding(get: { viewModel.item }, set: { viewModel.item = $0 })
self._tempItem = State(initialValue: viewModel.item)
self.itemType = viewModel.item.type!
}
// MARK: - Body
@ViewBuilder
var body: some View {
contentView
.navigationBarTitle(L10n.metadata)
.navigationBarTitleDisplayMode(.inline)
.topBarTrailing {
Button(L10n.save) {
item = tempItem
viewModel.send(.update(tempItem))
router.dismissCoordinator()
}
.buttonStyle(.toolbarPill)
.disabled(viewModel.item == tempItem)
}
.navigationBarCloseButton {
router.dismissCoordinator()
}
}
// MARK: - Content View
@ViewBuilder
private var contentView: some View {
Form {
TitleSection(item: $tempItem)
DateSection(
item: $tempItem,
itemType: itemType
)
if itemType == .series {
SeriesSection(item: $tempItem)
} else if itemType == .episode {
EpisodeSection(item: $tempItem)
}
OverviewSection(
item: $tempItem,
itemType: itemType
)
ReviewsSection(item: $tempItem)
ParentalRatingSection(item: $tempItem)
if [BaseItemKind.movie, .episode].contains(itemType) {
MediaFormatSection(item: $tempItem)
}
LocalizationSection(item: $tempItem)
LockMetadataSection(item: $tempItem)
}
}
}

View File

@ -18,8 +18,8 @@ struct ItemEditorView: View {
@EnvironmentObject
private var router: ItemEditorCoordinator.Router
@State
var item: BaseItemDto
@ObservedObject
var viewModel: ItemViewModel
// MARK: - Body
@ -30,10 +30,6 @@ struct ItemEditorView: View {
.navigationBarCloseButton {
router.dismissCoordinator()
}
.onNotification(.itemMetadataDidChange) { notification in
guard let newItem = notification.object as? BaseItemDto else { return }
item = newItem
}
}
// MARK: - Content View
@ -41,12 +37,20 @@ struct ItemEditorView: View {
private var contentView: some View {
List {
ListTitleSection(
item.name ?? L10n.unknown,
description: item.path
viewModel.item.name ?? L10n.unknown,
description: viewModel.item.path
)
refreshButtonView
editView
}
}
@ViewBuilder
private var refreshButtonView: some View {
Section {
RefreshMetadataButton(item: item)
RefreshMetadataButton(item: viewModel.item)
.environment(\.isEnabled, userSession?.user.isAdministrator ?? false)
} footer: {
LearnMoreButton(L10n.metadata) {
@ -69,5 +73,14 @@ struct ItemEditorView: View {
}
}
}
@ViewBuilder
private var editView: some View {
Section(L10n.edit) {
ChevronButton(L10n.metadata)
.onSelect {
router.route(to: \.editMetadata, viewModel.item)
}
}
}
}

View File

@ -39,7 +39,12 @@ struct ItemView: View {
enableItemDeletion && viewModel.item.canDelete ?? false
}
// As more menu items exist, this can either be expanded to include more validation or removed if there are permanent menu items.
private var canDownload: Bool {
viewModel.item.canDownload ?? false
}
// Use to hide the menu button when not needed.
// Add more checks as needed. For example, canDownload.
private var enableMenu: Bool {
canDelete || enableItemEditor
}
@ -123,12 +128,21 @@ struct ItemView: View {
.onFirstAppear {
viewModel.send(.refresh)
}
.topBarTrailing {
if viewModel.backgroundStates.contains(.refresh) {
ProgressView()
.navigationBarMenuButton(
isLoading: viewModel.backgroundStates.contains(.refresh),
isHidden: !enableMenu
) {
if enableItemEditor {
Button(L10n.edit, systemImage: "pencil") {
router.route(to: \.itemEditor, viewModel)
}
}
if canDelete {
Divider()
Button(L10n.delete, systemImage: "trash", role: .destructive) {
showConfirmationDialog = true
}
if enableMenu {
itemActionMenu
}
}
.confirmationDialog(
@ -159,27 +173,4 @@ struct ItemView: View {
Text(error.localizedDescription)
}
}
@ViewBuilder
private var itemActionMenu: some View {
Menu(L10n.options, systemImage: "ellipsis.circle") {
if enableItemEditor {
Button(L10n.edit, systemImage: "pencil") {
router.route(to: \.itemEditor, viewModel.item)
}
}
if canDelete {
Divider()
Button(L10n.delete, systemImage: "trash", role: .destructive) {
showConfirmationDialog = true
}
}
}
.labelStyle(.iconOnly)
.backport
.fontWeight(.semibold)
}
}

View File

@ -471,14 +471,9 @@ struct PagingLibraryView<Element: Poster>: View {
viewModel.send(.refresh)
}
}
.topBarTrailing {
if viewModel.backgroundStates.contains(.gettingNextPage) {
ProgressView()
}
Menu {
.navigationBarMenuButton(
isLoading: viewModel.backgroundStates.contains(.gettingNextPage)
) {
if Defaults[.Customization.Library.rememberLayout] {
LibraryViewTypeToggle(
posterType: $posterType,
@ -497,9 +492,6 @@ struct PagingLibraryView<Element: Poster>: View {
viewModel.send(.getRandomItem)
}
.disabled(viewModel.elements.isEmpty)
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}

File diff suppressed because it is too large Load Diff