mirror of
https://github.com/jellyfin/Swiftfin.git
synced 2024-11-23 05:59:51 +00:00
[iOS] Media Item Menu | Refresh Metadata & Delete Item (#1310)
* [iOS] Creation of the enableItemEditor & enableItemDeletion settings. Creation of the ItemEditorView. Creation of Refresh/Deletion Logic and Buttons. Wrap buttons in permissions.
* You can make delete permissions without edit (admin) permissions. So, flip this so you can get to the edit page but editing is disabled if you're not an admin. The Delete option requires that the delete toggle is enabled and the user has permissions.
* Move deletion from the editView to the ItemView
* Delete from PagingLibraryView on Deletion
* Only enable delete if the user can delete something. Check deletion permission on Item level. Only allow editing for admins.
* Review Changes: ec33a6b63c
* wip
* Update RefreshMetadataButton.swift
* Update Shared/ViewModels/ItemEditorViewModel/RefreshMetadataViewModel.swift
Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
* Update Shared/Coordinators/ItemEditorCoordinator.swift
Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
* Reviews minus a learn more button
* LearnMoreAttempt v1
* Learn more v2 - Much better
* Learn More v3
* Learn More comments cleanup
* Learn More: https://github.com/jellyfin/Swiftfin/pull/1310#discussion_r1843149572
* clean up
* Remove Replace since it's already covered. Localize.
* clean up
---------
Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
parent
128381a439
commit
687cfa6b5f
@ -28,6 +28,8 @@ final class ItemCoordinator: NavigationCoordinatable {
|
||||
@Route(.modal)
|
||||
var itemOverview = makeItemOverview
|
||||
@Route(.modal)
|
||||
var itemEditor = makeItemEditor
|
||||
@Route(.modal)
|
||||
var mediaSourceInfo = makeMediaSourceInfo
|
||||
@Route(.modal)
|
||||
var downloadTask = makeDownloadTask
|
||||
@ -78,6 +80,10 @@ final class ItemCoordinator: NavigationCoordinatable {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func makeItemEditor(item: BaseItemDto) -> NavigationViewCoordinator<ItemEditorCoordinator> {
|
||||
NavigationViewCoordinator(ItemEditorCoordinator(item: item))
|
||||
}
|
||||
|
||||
func makeDownloadTask(downloadTask: DownloadTask) -> NavigationViewCoordinator<DownloadTaskCoordinator> {
|
||||
NavigationViewCoordinator(DownloadTaskCoordinator(downloadTask: downloadTask))
|
||||
}
|
||||
|
30
Shared/Coordinators/ItemEditorCoordinator.swift
Normal file
30
Shared/Coordinators/ItemEditorCoordinator.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// 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 Stinsen
|
||||
import SwiftUI
|
||||
|
||||
final class ItemEditorCoordinator: ObservableObject, NavigationCoordinatable {
|
||||
|
||||
let stack = NavigationStack(initial: \ItemEditorCoordinator.start)
|
||||
|
||||
@Root
|
||||
var start = makeStart
|
||||
|
||||
private let item: BaseItemDto
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func makeStart() -> some View {
|
||||
ItemEditorView(item: item)
|
||||
}
|
||||
}
|
@ -81,6 +81,7 @@ extension Notifications.Key {
|
||||
static let didFailMigration = NotificationKey("didFailMigration")
|
||||
|
||||
static let itemMetadataDidChange = NotificationKey("itemMetadataDidChange")
|
||||
static let didDeleteItem = NotificationKey("didDeleteItem")
|
||||
|
||||
static let didConnectToServer = NotificationKey("didConnectToServer")
|
||||
static let didDeleteServer = NotificationKey("didDeleteServer")
|
||||
|
@ -52,6 +52,10 @@ internal enum L10n {
|
||||
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 media item deletion
|
||||
internal static let allowItemDeletion = L10n.tr("Localizable", "allowItemDeletion", fallback: "Allow media item deletion")
|
||||
/// Allow media item editing
|
||||
internal static let allowItemEditing = L10n.tr("Localizable", "allowItemEditing", fallback: "Allow media item editing")
|
||||
/// Select Server View - Select All Servers
|
||||
internal static let allServers = L10n.tr("Localizable", "allServers", fallback: "All Servers")
|
||||
/// View and manage all registered users on the server, including their permissions and activity status.
|
||||
@ -304,6 +308,8 @@ 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? 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.")
|
||||
/// Delete Selected Devices
|
||||
internal static let deleteSelectedDevices = L10n.tr("Localizable", "deleteSelectedDevices", fallback: "Delete Selected Devices")
|
||||
/// Delete Selected Users
|
||||
@ -410,6 +416,10 @@ internal enum L10n {
|
||||
internal static let filterResults = L10n.tr("Localizable", "filterResults", fallback: "Filter Results")
|
||||
/// Filters
|
||||
internal static let filters = L10n.tr("Localizable", "filters", fallback: "Filters")
|
||||
/// Find Missing
|
||||
internal static let findMissing = L10n.tr("Localizable", "findMissing", fallback: "Find Missing")
|
||||
/// Find missing metadata and images.
|
||||
internal static let findMissingDescription = L10n.tr("Localizable", "findMissingDescription", fallback: "Find missing metadata and images.")
|
||||
/// Transcode FPS
|
||||
internal static func fpsWithString(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "fpsWithString", String(describing: p1), fallback: "%@fps")
|
||||
@ -448,6 +458,8 @@ internal enum L10n {
|
||||
internal static func itemAtItem(_ p1: Any, _ p2: Any) -> String {
|
||||
return L10n.tr("Localizable", "itemAtItem", String(describing: p1), String(describing: p2), fallback: "%1$@ at %2$@")
|
||||
}
|
||||
/// You do not have permission to delete this item.
|
||||
internal static let itemDeletionPermissionFailure = L10n.tr("Localizable", "itemDeletionPermissionFailure", fallback: "You do not have permission to delete this item.")
|
||||
/// SessionPlaybackMethod Remaining Time
|
||||
internal static func itemOverItem(_ p1: Any, _ p2: Any) -> String {
|
||||
return L10n.tr("Localizable", "itemOverItem", String(describing: p1), String(describing: p2), fallback: "%1$@ / %2$@")
|
||||
@ -490,6 +502,8 @@ internal enum L10n {
|
||||
internal static func latestWithString(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "latestWithString", String(describing: p1), fallback: "Latest %@")
|
||||
}
|
||||
/// Learn more...
|
||||
internal static let learnMoreEllipsis = L10n.tr("Localizable", "learnMoreEllipsis", fallback: "Learn more...")
|
||||
/// Left
|
||||
internal static let `left` = L10n.tr("Localizable", "left", fallback: "Left")
|
||||
/// Letter Picker
|
||||
@ -526,6 +540,8 @@ internal enum L10n {
|
||||
internal static let media = L10n.tr("Localizable", "media", fallback: "Media")
|
||||
/// Menu Buttons
|
||||
internal static let menuButtons = L10n.tr("Localizable", "menuButtons", fallback: "Menu Buttons")
|
||||
/// Metadata
|
||||
internal static let metadata = L10n.tr("Localizable", "metadata", fallback: "Metadata")
|
||||
/// The play method (e.g., Direct Play, Transcoding)
|
||||
internal static let method = L10n.tr("Localizable", "method", fallback: "Method")
|
||||
/// Minutes
|
||||
@ -732,6 +748,8 @@ internal enum L10n {
|
||||
internal static let refFramesNotSupported = L10n.tr("Localizable", "refFramesNotSupported", fallback: "The number of reference frames is not supported")
|
||||
/// Refresh
|
||||
internal static let refresh = L10n.tr("Localizable", "refresh", fallback: "Refresh")
|
||||
/// Refresh Metadata
|
||||
internal static let refreshMetadata = L10n.tr("Localizable", "refreshMetadata", fallback: "Refresh Metadata")
|
||||
/// Regular
|
||||
internal static let regular = L10n.tr("Localizable", "regular", fallback: "Regular")
|
||||
/// Released
|
||||
@ -752,6 +770,18 @@ internal enum L10n {
|
||||
internal static let removeFromResume = L10n.tr("Localizable", "removeFromResume", fallback: "Remove From Resume")
|
||||
/// PlayMethod - Remux
|
||||
internal static let remux = L10n.tr("Localizable", "remux", fallback: "Remux")
|
||||
/// Replace All
|
||||
internal static let replaceAll = L10n.tr("Localizable", "replaceAll", fallback: "Replace All")
|
||||
/// Replace all unlocked metadata and images with new information.
|
||||
internal static let replaceAllDescription = L10n.tr("Localizable", "replaceAllDescription", fallback: "Replace all unlocked metadata and images with new information.")
|
||||
/// Replace Images
|
||||
internal static let replaceImages = L10n.tr("Localizable", "replaceImages", fallback: "Replace Images")
|
||||
/// Replace all images with new images.
|
||||
internal static let replaceImagesDescription = L10n.tr("Localizable", "replaceImagesDescription", fallback: "Replace all images with new images.")
|
||||
/// Replace Metadata
|
||||
internal static let replaceMetadata = L10n.tr("Localizable", "replaceMetadata", fallback: "Replace Metadata")
|
||||
/// Replace unlocked metadata with new information.
|
||||
internal static let replaceMetadataDescription = L10n.tr("Localizable", "replaceMetadataDescription", fallback: "Replace unlocked metadata with new information.")
|
||||
/// Report an Issue
|
||||
internal static let reportIssue = L10n.tr("Localizable", "reportIssue", fallback: "Report an Issue")
|
||||
/// Request a Feature
|
||||
|
@ -148,5 +148,21 @@ extension StoredValues.Keys {
|
||||
default: []
|
||||
)
|
||||
}
|
||||
|
||||
static var enableItemEditor: Key<Bool> {
|
||||
CurrentUserKey(
|
||||
"enableItemEditor",
|
||||
domain: "enableItemEditor",
|
||||
default: false
|
||||
)
|
||||
}
|
||||
|
||||
static var enableItemDeletion: Key<Bool> {
|
||||
CurrentUserKey(
|
||||
"enableItemDeletion",
|
||||
domain: "enableItemDeletion",
|
||||
default: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -68,6 +68,11 @@ extension UserState {
|
||||
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 pinHint: String {
|
||||
get {
|
||||
StoredValues[.User.pinHint(id: id)]
|
||||
|
111
Shared/ViewModels/ItemEditorViewModel/DeleteItemViewModel.swift
Normal file
111
Shared/ViewModels/ItemEditorViewModel/DeleteItemViewModel.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// 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 DeleteItemViewModel: ViewModel, Stateful, Eventful {
|
||||
|
||||
// MARK: Events
|
||||
|
||||
enum Event: Equatable {
|
||||
case error(JellyfinAPIError)
|
||||
case deleted
|
||||
}
|
||||
|
||||
// MARK: Action
|
||||
|
||||
enum Action: Equatable {
|
||||
case error(JellyfinAPIError)
|
||||
case delete
|
||||
}
|
||||
|
||||
// MARK: State
|
||||
|
||||
enum State: Hashable {
|
||||
case content
|
||||
case error(JellyfinAPIError)
|
||||
case initial
|
||||
case refreshing
|
||||
}
|
||||
|
||||
@Published
|
||||
var item: BaseItemDto?
|
||||
|
||||
@Published
|
||||
final var state: State = .initial
|
||||
|
||||
private var deleteTask: AnyCancellable?
|
||||
|
||||
// MARK: Event Variables
|
||||
|
||||
private var eventSubject: PassthroughSubject<Event, Never> = .init()
|
||||
|
||||
var events: AnyPublisher<Event, Never> {
|
||||
eventSubject
|
||||
.receive(on: RunLoop.main)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: Init
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self.item = item
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: Respond
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case let .error(error):
|
||||
return .error(error)
|
||||
|
||||
case .delete:
|
||||
deleteTask?.cancel()
|
||||
|
||||
deleteTask = Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
try await self.deleteItem()
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
self.eventSubject.send(.deleted)
|
||||
}
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .error(JellyfinAPIError(error.localizedDescription))
|
||||
self.eventSubject.send(.error(JellyfinAPIError(error.localizedDescription)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .refreshing
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Metadata Refresh Logic
|
||||
|
||||
private func deleteItem() async throws {
|
||||
guard let itemID = item?.id else {
|
||||
throw JellyfinAPIError(L10n.unknownError)
|
||||
}
|
||||
|
||||
let request = Paths.deleteItem(itemID: itemID)
|
||||
_ = try await userSession.client.send(request)
|
||||
|
||||
await MainActor.run {
|
||||
Notifications[.didDeleteItem].post(object: item)
|
||||
self.item = nil
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,175 @@
|
||||
//
|
||||
// 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 RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
|
||||
|
||||
// MARK: Events
|
||||
|
||||
enum Event: Equatable {
|
||||
case error(JellyfinAPIError)
|
||||
case refreshTriggered
|
||||
}
|
||||
|
||||
// MARK: Action
|
||||
|
||||
enum Action: Equatable {
|
||||
case error(JellyfinAPIError)
|
||||
case refreshMetadata(
|
||||
metadataRefreshMode: MetadataRefreshMode,
|
||||
imageRefreshMode: MetadataRefreshMode,
|
||||
replaceMetadata: Bool,
|
||||
replaceImages: Bool
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: State
|
||||
|
||||
enum State: Hashable {
|
||||
case content
|
||||
case error(JellyfinAPIError)
|
||||
case initial
|
||||
case refreshing
|
||||
}
|
||||
|
||||
// A spoof progress, since there isn't a
|
||||
// single item metadata refresh task
|
||||
@Published
|
||||
private(set) var progress: Double = 0.0
|
||||
|
||||
@Published
|
||||
private var item: BaseItemDto
|
||||
@Published
|
||||
final var state: State = .initial
|
||||
|
||||
private var itemTask: AnyCancellable?
|
||||
private var 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
|
||||
|
||||
func respond(to action: Action) -> State {
|
||||
switch action {
|
||||
case let .error(error):
|
||||
eventSubject.send(.error(error))
|
||||
return .error(error)
|
||||
|
||||
case let .refreshMetadata(metadataRefreshMode, imageRefreshMode, replaceMetadata, replaceImages):
|
||||
itemTask?.cancel()
|
||||
|
||||
itemTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
self.eventSubject.send(.refreshTriggered)
|
||||
}
|
||||
|
||||
try await self.refreshMetadata(
|
||||
metadataRefreshMode: metadataRefreshMode,
|
||||
imageRefreshMode: imageRefreshMode,
|
||||
replaceMetadata: replaceMetadata,
|
||||
replaceImages: replaceImages
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .refreshing
|
||||
self.eventSubject.send(.refreshTriggered)
|
||||
}
|
||||
|
||||
try await self.refreshItem()
|
||||
|
||||
await MainActor.run {
|
||||
self.state = .content
|
||||
}
|
||||
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
let apiError = JellyfinAPIError(error.localizedDescription)
|
||||
await MainActor.run {
|
||||
self.state = .error(apiError)
|
||||
self.eventSubject.send(.error(apiError))
|
||||
}
|
||||
}
|
||||
}
|
||||
.asAnyCancellable()
|
||||
|
||||
return .refreshing
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Metadata Refresh Logic
|
||||
|
||||
private func refreshMetadata(
|
||||
metadataRefreshMode: MetadataRefreshMode,
|
||||
imageRefreshMode: MetadataRefreshMode,
|
||||
replaceMetadata: Bool = false,
|
||||
replaceImages: Bool = false
|
||||
) async throws {
|
||||
guard let itemId = item.id else { return }
|
||||
|
||||
var parameters = Paths.RefreshItemParameters()
|
||||
parameters.metadataRefreshMode = metadataRefreshMode
|
||||
parameters.imageRefreshMode = imageRefreshMode
|
||||
parameters.isReplaceAllMetadata = replaceMetadata
|
||||
parameters.isReplaceAllImages = replaceImages
|
||||
|
||||
let request = Paths.refreshItem(
|
||||
itemID: itemId,
|
||||
parameters: parameters
|
||||
)
|
||||
_ = try await userSession.client.send(request)
|
||||
}
|
||||
|
||||
// MARK: Refresh Item After Request Queued
|
||||
|
||||
private func refreshItem() async throws {
|
||||
guard let itemId = item.id else { return }
|
||||
|
||||
let totalDuration: Double = 5.0
|
||||
let interval: Double = 0.05
|
||||
let steps = Int(totalDuration / interval)
|
||||
|
||||
// Update progress every 0.05 seconds. Ticks up "1%" at a time.
|
||||
for i in 1 ... steps {
|
||||
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
||||
|
||||
let currentProgress = Double(i) / Double(steps)
|
||||
await MainActor.run {
|
||||
self.progress = currentProgress
|
||||
}
|
||||
}
|
||||
|
||||
// After waiting for 5 seconds, fetch the updated item
|
||||
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.progress = 0.0
|
||||
|
||||
Notifications[.itemMetadataDidChange].post(object: itemId)
|
||||
}
|
||||
}
|
||||
}
|
@ -159,6 +159,13 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
|
||||
|
||||
super.init()
|
||||
|
||||
Notifications[.didDeleteItem].publisher
|
||||
.sink(receiveCompletion: { _ in }) { [weak self] notification in
|
||||
guard let item = notification.object as? Element else { return }
|
||||
self?.elements.remove(item)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
if let filterViewModel {
|
||||
filterViewModel.$currentFilters
|
||||
.dropFirst()
|
||||
|
@ -9,6 +9,7 @@
|
||||
/* 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 */; };
|
||||
4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0195E32CE04678007844F4 /* ItemSection.swift */; };
|
||||
4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; };
|
||||
4E0A8FFB2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; };
|
||||
4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; };
|
||||
@ -73,6 +74,13 @@
|
||||
4E762AAF2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; };
|
||||
4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; };
|
||||
4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; };
|
||||
4E8F74A22CE03C9000CC8969 /* ItemEditorCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */; };
|
||||
4E8F74A52CE03D3C00CC8969 /* ItemEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */; };
|
||||
4E8F74AB2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */; };
|
||||
4E8F74AC2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */; };
|
||||
4E8F74AF2CE03E2E00CC8969 /* RefreshMetadataButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74AD2CE03E2E00CC8969 /* RefreshMetadataButton.swift */; };
|
||||
4E8F74B12CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */; };
|
||||
4E8F74B22CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */; };
|
||||
4E90F7642CC72B1F00417C31 /* LastRunSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75B2CC72B1F00417C31 /* LastRunSection.swift */; };
|
||||
4E90F7652CC72B1F00417C31 /* EditServerTaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F7612CC72B1F00417C31 /* EditServerTaskView.swift */; };
|
||||
4E90F7662CC72B1F00417C31 /* LastErrorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E90F75A2CC72B1F00417C31 /* LastErrorSection.swift */; };
|
||||
@ -123,6 +131,7 @@
|
||||
4EF18B282CB9936D00343666 /* ListColumnsPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B272CB9936400343666 /* ListColumnsPickerView.swift */; };
|
||||
4EF18B2A2CB993BD00343666 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF18B292CB993AD00343666 /* ListRow.swift */; };
|
||||
4EF659E32CDD270D00E0BE5D /* ActionMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF659E22CDD270B00E0BE5D /* ActionMenu.swift */; };
|
||||
4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.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 */; };
|
||||
@ -1066,6 +1075,7 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
|
||||
4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = "<group>"; };
|
||||
4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = "<group>"; };
|
||||
4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailsView.swift; sourceTree = "<group>"; };
|
||||
4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilitiesSection.swift; sourceTree = "<group>"; };
|
||||
@ -1112,6 +1122,11 @@
|
||||
4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = "<group>"; };
|
||||
4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = "<group>"; };
|
||||
4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = "<group>"; };
|
||||
4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorCoordinator.swift; sourceTree = "<group>"; };
|
||||
4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorView.swift; sourceTree = "<group>"; };
|
||||
4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteItemViewModel.swift; sourceTree = "<group>"; };
|
||||
4E8F74AD2CE03E2E00CC8969 /* RefreshMetadataButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshMetadataButton.swift; sourceTree = "<group>"; };
|
||||
4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshMetadataViewModel.swift; sourceTree = "<group>"; };
|
||||
4E90F7592CC72B1F00417C31 /* DetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailsSection.swift; sourceTree = "<group>"; };
|
||||
4E90F75A2CC72B1F00417C31 /* LastErrorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastErrorSection.swift; sourceTree = "<group>"; };
|
||||
4E90F75B2CC72B1F00417C31 /* LastRunSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastRunSection.swift; sourceTree = "<group>"; };
|
||||
@ -1156,6 +1171,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -2073,6 +2089,7 @@
|
||||
4E699BB72CB33FB0007CBD5D /* Sections */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E0195E32CE04678007844F4 /* ItemSection.swift */,
|
||||
4E699BB82CB33FB5007CBD5D /* HomeSection.swift */,
|
||||
);
|
||||
path = Sections;
|
||||
@ -2113,6 +2130,32 @@
|
||||
path = ActiveSessionDetailView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E8F74A32CE03D3100CC8969 /* ItemEditorView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E8F74A62CE03D4C00CC8969 /* Components */,
|
||||
4E8F74A42CE03D3800CC8969 /* ItemEditorView.swift */,
|
||||
);
|
||||
path = ItemEditorView;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E8F74A62CE03D4C00CC8969 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E8F74AD2CE03E2E00CC8969 /* RefreshMetadataButton.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4E8F74AA2CE03DC600CC8969 /* DeleteItemViewModel.swift */,
|
||||
4E8F74B02CE03EAF00CC8969 /* RefreshMetadataViewModel.swift */,
|
||||
);
|
||||
path = ItemEditorViewModel;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4E90F75E2CC72B1F00417C31 /* Sections */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -2322,6 +2365,7 @@
|
||||
E17AC96E2954EE4B003D2BC2 /* DownloadListViewModel.swift */,
|
||||
E113133928BEB71D00930F75 /* FilterViewModel.swift */,
|
||||
625CB5722678C32A00530A6E /* HomeViewModel.swift */,
|
||||
4E8F74A92CE03DBE00CC8969 /* ItemEditorViewModel */,
|
||||
E107BB9127880A4000354E07 /* ItemViewModel */,
|
||||
E1EDA8D52B924CA500F9A57E /* LibraryViewModel */,
|
||||
C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */,
|
||||
@ -2719,6 +2763,7 @@
|
||||
E178B0752BE435D70023651B /* HourMinutePicker.swift */,
|
||||
E1DC7AC92C63337C00AEE368 /* iOS15View.swift */,
|
||||
E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */,
|
||||
4EFD172D2CE4181F00A4BAC5 /* LearnMoreButton.swift */,
|
||||
4E16FD4E2C0183B500110147 /* LetterPickerBar */,
|
||||
E1A8FDEB2C0574A800D0A51C /* ListRow.swift */,
|
||||
E1AEFA362BE317E200CFAFD8 /* ListRowButton.swift */,
|
||||
@ -2809,6 +2854,7 @@
|
||||
6220D0B926D6092100B8E046 /* FilterCoordinator.swift */,
|
||||
62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */,
|
||||
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */,
|
||||
4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */,
|
||||
6220D0B326D5ED8000B8E046 /* LibraryCoordinator.swift */,
|
||||
E102312B2BCF8A08009D71FC /* LiveTVCoordinator */,
|
||||
C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */,
|
||||
@ -3296,6 +3342,7 @@
|
||||
62C83B07288C6A630004ED0C /* FontPickerView.swift */,
|
||||
E168BD07289A4162001A6922 /* HomeView */,
|
||||
E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */,
|
||||
4E8F74A32CE03D3100CC8969 /* ItemEditorView */,
|
||||
E14F7D0A26DB3714007C3AE6 /* ItemView */,
|
||||
E170D104294D21FA0017224C /* MediaSourceInfoView.swift */,
|
||||
E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */,
|
||||
@ -4634,6 +4681,7 @@
|
||||
E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */,
|
||||
E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */,
|
||||
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */,
|
||||
4E8F74AB2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */,
|
||||
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
|
||||
E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */,
|
||||
4E35CE6D2CBEDB7600DBD886 /* TaskState.swift in Sources */,
|
||||
@ -4871,6 +4919,7 @@
|
||||
E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */,
|
||||
E1AEFA382BE36C4900CFAFD8 /* SwiftinStore+UserState.swift in Sources */,
|
||||
E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
|
||||
4E8F74B12CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */,
|
||||
E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */,
|
||||
E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */,
|
||||
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
|
||||
@ -5099,6 +5148,7 @@
|
||||
E13332942953BAA100EE76AB /* DownloadTaskContentView.swift in Sources */,
|
||||
4E14DC032CD43DD2001B621B /* AdminDashboardCoordinator.swift in Sources */,
|
||||
E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */,
|
||||
4E8F74B22CE03EB000CC8969 /* RefreshMetadataViewModel.swift in Sources */,
|
||||
E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */,
|
||||
E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */,
|
||||
E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */,
|
||||
@ -5116,12 +5166,14 @@
|
||||
E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */,
|
||||
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
|
||||
E15756322935642A00976E1F /* Double.swift in Sources */,
|
||||
4EFD172E2CE4182200A4BAC5 /* LearnMoreButton.swift in Sources */,
|
||||
E139CC1D28EC836F00688DE2 /* ChapterOverlay.swift in Sources */,
|
||||
E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */,
|
||||
E10B1EB42BD9803100A92EAF /* UserRow.swift in Sources */,
|
||||
E1E6C45029B104840064123F /* Button.swift in Sources */,
|
||||
4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */,
|
||||
E1153DCC2BBB633B00424D36 /* FastSVGView.swift in Sources */,
|
||||
4E8F74A52CE03D3C00CC8969 /* ItemEditorView.swift in Sources */,
|
||||
E10432F62BE4426F006FF9DD /* FormatStyle.swift in Sources */,
|
||||
E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */,
|
||||
E14EDEC52B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */,
|
||||
@ -5226,10 +5278,13 @@
|
||||
6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */,
|
||||
4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */,
|
||||
4EED874A2CBF824B002354D2 /* DeviceRow.swift in Sources */,
|
||||
4E8F74AC2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */,
|
||||
4EED874B2CBF824B002354D2 /* DevicesView.swift in Sources */,
|
||||
E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */,
|
||||
4E8F74A22CE03C9000CC8969 /* ItemEditorCoordinator.swift in Sources */,
|
||||
E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */,
|
||||
E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */,
|
||||
4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */,
|
||||
E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
|
||||
E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
|
||||
E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */,
|
||||
@ -5449,6 +5504,7 @@
|
||||
E18E01EA288747230022598C /* MovieItemView.swift in Sources */,
|
||||
6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */,
|
||||
E164A7F42BE4736300A54B18 /* SignOutIntervalSection.swift in Sources */,
|
||||
4E8F74AF2CE03E2E00CC8969 /* RefreshMetadataButton.swift in Sources */,
|
||||
E148128528C15472003B8787 /* SortOrder+ItemSortOrder.swift in Sources */,
|
||||
E10231602BCF8B7E009D71FC /* VideoPlayerWrapperCoordinator.swift in Sources */,
|
||||
E1D842172932AB8F00D1041A /* NativeVideoPlayer.swift in Sources */,
|
||||
|
62
Swiftfin/Components/LearnMoreButton.swift
Normal file
62
Swiftfin/Components/LearnMoreButton.swift
Normal file
@ -0,0 +1,62 @@
|
||||
//
|
||||
// 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 LearnMoreButton: View {
|
||||
|
||||
@State
|
||||
private var isPresented: Bool = false
|
||||
|
||||
private let title: String
|
||||
private let items: [TextPair]
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(_ title: String, @ArrayBuilder<TextPair> items: () -> [TextPair]) {
|
||||
self.title = title
|
||||
self.items = items()
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Button(L10n.learnMoreEllipsis) {
|
||||
isPresented = true
|
||||
}
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.font(.subheadline)
|
||||
.sheet(isPresented: $isPresented) {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(items) { content in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(content.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(content.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.edgePadding()
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarCloseButton {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,127 @@
|
||||
//
|
||||
// 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 ItemEditorView {
|
||||
|
||||
struct RefreshMetadataButton: View {
|
||||
|
||||
// Bug in SwiftUI where Menu item icons will be black in dark mode
|
||||
// when a HierarchicalShapeStyle is applied to the Buttons
|
||||
@Environment(\.colorScheme)
|
||||
private var colorScheme: ColorScheme
|
||||
|
||||
@StateObject
|
||||
private var viewModel: RefreshMetadataViewModel
|
||||
|
||||
@State
|
||||
private var isPresentingEventAlert = false
|
||||
@State
|
||||
private var error: JellyfinAPIError?
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
_viewModel = StateObject(wrappedValue: RefreshMetadataViewModel(item: item))
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Group {
|
||||
Button(L10n.findMissing, systemImage: "magnifyingglass") {
|
||||
viewModel.send(
|
||||
.refreshMetadata(
|
||||
metadataRefreshMode: .fullRefresh,
|
||||
imageRefreshMode: .fullRefresh,
|
||||
replaceMetadata: false,
|
||||
replaceImages: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Button(L10n.replaceMetadata, systemImage: "arrow.clockwise") {
|
||||
viewModel.send(
|
||||
.refreshMetadata(
|
||||
metadataRefreshMode: .fullRefresh,
|
||||
imageRefreshMode: .none,
|
||||
replaceMetadata: true,
|
||||
replaceImages: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Button(L10n.replaceImages, systemImage: "photo") {
|
||||
viewModel.send(
|
||||
.refreshMetadata(
|
||||
metadataRefreshMode: .none,
|
||||
imageRefreshMode: .fullRefresh,
|
||||
replaceMetadata: false,
|
||||
replaceImages: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Button(L10n.replaceAll, systemImage: "staroflife") {
|
||||
viewModel.send(
|
||||
.refreshMetadata(
|
||||
metadataRefreshMode: .fullRefresh,
|
||||
imageRefreshMode: .fullRefresh,
|
||||
replaceMetadata: true,
|
||||
replaceImages: true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(L10n.refreshMetadata)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.state == .refreshing {
|
||||
ProgressView(value: viewModel.progress)
|
||||
.progressViewStyle(.gauge)
|
||||
.transition(.opacity.combined(with: .scale).animation(.bouncy))
|
||||
.frame(width: 25, height: 25)
|
||||
} else {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.foregroundStyle(.secondary)
|
||||
.backport
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary, .secondary)
|
||||
.disabled(viewModel.state == .refreshing || isPresentingEventAlert)
|
||||
.onReceive(viewModel.events) { event in
|
||||
switch event {
|
||||
case let .error(eventError):
|
||||
error = eventError
|
||||
isPresentingEventAlert = true
|
||||
case .refreshTriggered:
|
||||
UIDevice.impact(.light)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
L10n.error,
|
||||
isPresented: $isPresentingEventAlert,
|
||||
presenting: error
|
||||
) { _ in
|
||||
|
||||
} message: { error in
|
||||
Text(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
73
Swiftfin/Views/ItemEditorView/ItemEditorView.swift
Normal file
73
Swiftfin/Views/ItemEditorView/ItemEditorView.swift
Normal file
@ -0,0 +1,73 @@
|
||||
//
|
||||
// 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 Factory
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct ItemEditorView: View {
|
||||
|
||||
@Injected(\.currentUserSession)
|
||||
private var userSession
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: ItemEditorCoordinator.Router
|
||||
|
||||
@State
|
||||
var item: BaseItemDto
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
contentView
|
||||
.navigationBarTitle(L10n.metadata)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarCloseButton {
|
||||
router.dismissCoordinator()
|
||||
}
|
||||
.onNotification(.itemMetadataDidChange) { notification in
|
||||
guard let newItem = notification.object as? BaseItemDto else { return }
|
||||
item = newItem
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content View
|
||||
|
||||
private var contentView: some View {
|
||||
List {
|
||||
ListTitleSection(
|
||||
item.name ?? L10n.unknown,
|
||||
description: item.path
|
||||
)
|
||||
|
||||
Section {
|
||||
RefreshMetadataButton(item: item)
|
||||
.environment(\.isEnabled, userSession?.user.isAdministrator ?? false)
|
||||
} footer: {
|
||||
LearnMoreButton(L10n.metadata) {
|
||||
TextPair(
|
||||
title: L10n.findMissing,
|
||||
subtitle: L10n.findMissingDescription
|
||||
)
|
||||
TextPair(
|
||||
title: L10n.replaceMetadata,
|
||||
subtitle: L10n.replaceMetadataDescription
|
||||
)
|
||||
TextPair(
|
||||
title: L10n.replaceImages,
|
||||
subtitle: L10n.replaceImagesDescription
|
||||
)
|
||||
TextPair(
|
||||
title: L10n.replaceAll,
|
||||
subtitle: L10n.replaceAllDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -15,8 +15,34 @@ import SwiftUI
|
||||
|
||||
struct ItemView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var router: ItemCoordinator.Router
|
||||
|
||||
@StateObject
|
||||
private var viewModel: ItemViewModel
|
||||
@StateObject
|
||||
private var deleteViewModel: DeleteItemViewModel
|
||||
|
||||
@State
|
||||
private var showConfirmationDialog = false
|
||||
@State
|
||||
private var isPresentingEventAlert = false
|
||||
@State
|
||||
private var error: JellyfinAPIError?
|
||||
|
||||
@StoredValue(.User.enableItemDeletion)
|
||||
private var enableItemDeletion: Bool
|
||||
@StoredValue(.User.enableItemEditor)
|
||||
private var enableItemEditor: Bool
|
||||
|
||||
private var canDelete: Bool {
|
||||
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 enableMenu: Bool {
|
||||
canDelete || enableItemEditor
|
||||
}
|
||||
|
||||
private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel {
|
||||
switch item.type {
|
||||
@ -36,6 +62,7 @@ struct ItemView: View {
|
||||
|
||||
init(item: BaseItemDto) {
|
||||
self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item))
|
||||
self._deleteViewModel = StateObject(wrappedValue: DeleteItemViewModel(item: item))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -100,6 +127,59 @@ struct ItemView: View {
|
||||
if viewModel.backgroundStates.contains(.refresh) {
|
||||
ProgressView()
|
||||
}
|
||||
if enableMenu {
|
||||
itemActionMenu
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
L10n.deleteItemConfirmationMessage,
|
||||
isPresented: $showConfirmationDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(L10n.confirm, role: .destructive) {
|
||||
deleteViewModel.send(.delete)
|
||||
}
|
||||
Button(L10n.cancel, role: .cancel) {}
|
||||
}
|
||||
.onReceive(deleteViewModel.events) { event in
|
||||
switch event {
|
||||
case let .error(eventError):
|
||||
error = eventError
|
||||
isPresentingEventAlert = true
|
||||
case .deleted:
|
||||
router.dismissCoordinator()
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
L10n.error,
|
||||
isPresented: $isPresentingEventAlert,
|
||||
presenting: error
|
||||
) { _ in
|
||||
} message: { error in
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,38 @@
|
||||
//
|
||||
// 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 Factory
|
||||
import SwiftUI
|
||||
|
||||
extension CustomizeViewsSettings {
|
||||
|
||||
struct ItemSection: View {
|
||||
|
||||
@Injected(\.currentUserSession)
|
||||
private var userSession
|
||||
|
||||
@StoredValue(.User.enableItemEditor)
|
||||
private var enableItemEditor
|
||||
@StoredValue(.User.enableItemDeletion)
|
||||
private var enableItemDeletion
|
||||
|
||||
var body: some View {
|
||||
Section(L10n.items) {
|
||||
|
||||
if userSession?.user.isAdministrator ?? false {
|
||||
Toggle(L10n.allowItemEditing, isOn: $enableItemEditor)
|
||||
}
|
||||
|
||||
if userSession?.user.hasDeletionPermissions ?? false {
|
||||
Toggle(L10n.allowItemDeletion, isOn: $enableItemDeletion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -158,6 +158,8 @@ struct CustomizeViewsSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
ItemSection()
|
||||
|
||||
HomeSection()
|
||||
|
||||
Section {
|
||||
|
@ -1213,6 +1213,61 @@
|
||||
// Used as the button label in the options menu when there are users to edit
|
||||
"editUsers" = "Edit Users";
|
||||
|
||||
// Refresh - Button
|
||||
// Button title for the menu to refresh metadata
|
||||
// Used as the label for the refresh metadata button
|
||||
"refreshMetadata" = "Refresh Metadata";
|
||||
|
||||
// Find Missing - Menu Option
|
||||
// Menu option for finding missing metadata
|
||||
// Used to trigger a full metadata refresh
|
||||
"findMissing" = "Find Missing";
|
||||
|
||||
// Replace Metadata - Menu Option
|
||||
// Menu option for replacing existing metadata
|
||||
// Used to trigger replacing metadata only
|
||||
"replaceMetadata" = "Replace Metadata";
|
||||
|
||||
// Replace Images - Menu Option
|
||||
// Menu option for replacing existing images
|
||||
// Used to trigger replacing images only
|
||||
"replaceImages" = "Replace Images";
|
||||
|
||||
// Replace All - Menu Option
|
||||
// Menu option for replacing both metadata and images
|
||||
// Used to trigger a full replacement of metadata and images
|
||||
"replaceAll" = "Replace All";
|
||||
|
||||
// Delete Item Confirmation Message - Warning message
|
||||
// Warning message to confirm deleting a media item
|
||||
// Used in a confirmation for item deletion
|
||||
"deleteItemConfirmationMessage" = "Are you sure you want to delete this item? This action cannot be undone.";
|
||||
|
||||
// Allow Media Item Editing - Toggle
|
||||
// Toggle option for enabling media item editing
|
||||
// Used to allow users to edit metadata of media items
|
||||
"allowItemEditing" = "Allow media item editing";
|
||||
|
||||
// Allow Media Item Deletion - Toggle
|
||||
// Toggle option for enabling media item deletion
|
||||
// Used to allow users to delete media items
|
||||
"allowItemDeletion" = "Allow media item deletion";
|
||||
|
||||
// Item Deletion Permission Failure - Error Message
|
||||
// Alert the user they should not be able to delete something
|
||||
// Used to inform the user a deletion failed and why it failed
|
||||
"itemDeletionPermissionFailure" = "You do not have permission to delete this item.";
|
||||
|
||||
// Metadata - Section Title
|
||||
// Title for the ItemEditorView and Metadata related views
|
||||
// Used as a title for sections/views related to Metadata
|
||||
"metadata" = "Metadata";
|
||||
|
||||
// Learn More - Button
|
||||
// Opens a modal with more information
|
||||
// Used as a button to show details
|
||||
"learnMoreEllipsis" = "Learn more...";
|
||||
|
||||
/// Current Password - Placeholder
|
||||
/// Placeholder text for the current password input field
|
||||
/// Used in the ResetUserPasswordView
|
||||
@ -1247,3 +1302,23 @@
|
||||
/// Message displayed to alert the user what the password change does and does not do
|
||||
/// Used in the ResetUserPasswordView
|
||||
"passwordChangeWarning" = "Changes the Jellyfin server user password. This does not change any Swiftfin settings.";
|
||||
|
||||
/// Find Missing - Button
|
||||
/// Search for missing metadata and images
|
||||
/// Used to locate media files missing information or images
|
||||
"findMissingDescription" = "Find missing metadata and images.";
|
||||
|
||||
/// Replace Metadata - Button
|
||||
/// Overwrite metadata without affecting images
|
||||
/// Used when updating only metadata information
|
||||
"replaceMetadataDescription" = "Replace unlocked metadata with new information.";
|
||||
|
||||
/// Replace Images - Button
|
||||
/// Overwrite existing images with new ones
|
||||
/// Used to refresh media artwork
|
||||
"replaceImagesDescription" = "Replace all images with new images.";
|
||||
|
||||
/// Replace All - Button
|
||||
/// Replace all metadata and images
|
||||
/// Full refresh that replaces all unlocked metadata and images
|
||||
"replaceAllDescription" = "Replace all unlocked metadata and images with new information.";
|
||||
|
Loading…
Reference in New Issue
Block a user