Skip to content

Commit

Permalink
[tvOS] Media Item Menu - Refresh / Delete Items (jellyfin#1348)
Browse files Browse the repository at this point in the history
* Mirror tvOS to iOS

* Fix router dismiss. Remove redundent viewModel.refresh from itemView

* reset dev team info

* View Modifier and ViewModel cleanup

* Remove testing comments / events

* Cleanup `.errorMessage($error)`

* Cleanup all viewModel.states for item editing, add errorViews if the data fails to load, and add errorMessage on failed events.

MARK sections: Var/Func always unless only Body and Var/Lets only if there are several of varying types / functions.
  • Loading branch information
JPKribs authored Dec 10, 2024
1 parent bbfa944 commit 548d35b
Show file tree
Hide file tree
Showing 24 changed files with 672 additions and 237 deletions.
35 changes: 35 additions & 0 deletions Shared/Extensions/ViewExtensions/Modifiers/ErrorMessage.swift
Original file line number Diff line number Diff line change
@@ -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 SwiftUI

struct ErrorMessageModifier: ViewModifier {

@Binding
var error: Error?

let dismissActions: (() -> Void)?

// MARK: - Body

func body(content: Content) -> some View {
content
.alert(
L10n.error.text,
isPresented: .constant(error != nil),
presenting: error
) { _ in
Button(L10n.dismiss, role: .cancel) {
error = nil
dismissActions?()
}
} message: { error in
Text(error.localizedDescription)
}
}
}
9 changes: 9 additions & 0 deletions Shared/Extensions/ViewExtensions/ViewExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,15 @@ extension View {
modifier(BottomEdgeGradientModifier(bottomColor: bottomColor))
}

/// Error Message Alert
func errorMessage(
_ error: Binding<Error?>,
dismissActions: (() -> Void)? = nil
) -> some View {
modifier(ErrorMessageModifier(error: error, dismissActions: dismissActions))
}

/// Apply a corner radius as a ratio of a view's side
func posterShadow() -> some View {
shadow(radius: 4, y: 2)
}
Expand Down
39 changes: 17 additions & 22 deletions Shared/ViewModels/ItemAdministration/DeleteItemViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,61 +12,57 @@ import JellyfinAPI

class DeleteItemViewModel: ViewModel, Stateful, Eventful {

// MARK: Events
// MARK: - Events

enum Event: Equatable {
case error(JellyfinAPIError)
case deleted
case error(JellyfinAPIError)
}

// MARK: Action
// MARK: - Action

enum Action: Equatable {
case error(JellyfinAPIError)
case delete
}

// MARK: State
// MARK: - State

enum State: Hashable {
case content
case error(JellyfinAPIError)
case initial
case refreshing
case error(JellyfinAPIError)
}

@Published
var item: BaseItemDto?

@Published
final var state: State = .initial

private var deleteTask: AnyCancellable?
// MARK: - Published Item

@Published
var item: BaseItemDto?

// MARK: Event Variables

private var deleteTask: AnyCancellable?
private var eventSubject: PassthroughSubject<Event, Never> = .init()

var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
// Causes issues with the Deleted Event unless this is removed
// .receive(on: RunLoop.main)
}

// MARK: Init
// MARK: - Initializer

init(item: BaseItemDto) {
self.item = item
super.init()
}

// MARK: Respond
// MARK: - Respond

func respond(to action: Action) -> State {
switch action {
case let .error(error):
return .error(error)

case .delete:
deleteTask?.cancel()

Expand All @@ -75,12 +71,11 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful {
do {
try await self.deleteItem()
await MainActor.run {
self.state = .content
self.state = .initial
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)))
Expand All @@ -89,11 +84,11 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful {
}
.asAnyCancellable()

return .refreshing
return .initial
}
}

// MARK: Metadata Refresh Logic
// MARK: - Item Deletion Logic

private func deleteItem() async throws {
guard let item, let itemID = item.id else {
Expand Down
81 changes: 37 additions & 44 deletions Shared/ViewModels/ItemAdministration/RefreshMetadataViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,15 @@ import JellyfinAPI

class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {

// MARK: Events
// MARK: - Events

enum Event: Equatable {
case error(JellyfinAPIError)
case refreshTriggered
}

// MARK: Action
// MARK: - Action

enum Action: Equatable {
case error(JellyfinAPIError)
case refreshMetadata(
metadataRefreshMode: MetadataRefreshMode,
imageRefreshMode: MetadataRefreshMode,
Expand All @@ -31,24 +29,24 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
)
}

// MARK: State
// MARK: States

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
final var state: State = .initial

// MARK: - Published Items

@Published
private var item: BaseItemDto
private(set) var progress: Double = 0.0
@Published
final var state: State = .initial
private var item: BaseItemDto

// MARK: - Event Objects

private var itemTask: AnyCancellable?
private var eventSubject = PassthroughSubject<Event, Never>()
Expand All @@ -59,30 +57,25 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
.eraseToAnyPublisher()
}

// MARK: Init
// MARK: - Init

init(item: BaseItemDto) {
self.item = item
super.init()
}

// MARK: Respond
// 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)
self.state = .refreshing
}

try await self.refreshMetadata(
Expand All @@ -93,33 +86,25 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
)

await MainActor.run {
self.state = .refreshing
self.eventSubject.send(.refreshTriggered)
}

try await self.refreshItem()

await MainActor.run {
self.state = .content
self.state = .initial
}

} 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
return state
}
}

// MARK: Metadata Refresh Logic
// MARK: - Metadata Refresh Logic

private func refreshMetadata(
metadataRefreshMode: MetadataRefreshMode,
Expand All @@ -140,18 +125,37 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
parameters: parameters
)
_ = try await userSession.client.send(request)

try await self.refreshItem()
}

// MARK: Refresh Item After Request Queued
// MARK: - Refresh Item After Request Queued

private func refreshItem() async throws {
guard let itemId = item.id else { return }

try await pollRefreshProgress()

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(self.item)
}
}

// MARK: - Poll Progress

// TODO: Find a way to actually check refresh progress. Not currently possible on 10.10.
private func pollRefreshProgress() async throws {
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.
/// 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))

Expand All @@ -160,16 +164,5 @@ class RefreshMetadataViewModel: ViewModel, Stateful, Eventful {
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(item)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ extension ItemView {

struct ActionButton: View {

// MARK: - Environment Objects

@Environment(\.isSelected)
private var isSelected

// MARK: - Focus State

@FocusState
private var isFocused: Bool

// MARK: - Item Variables

let title: String
let icon: String
let selectedIcon: String

// MARK: - Item Actions

let onSelect: () -> Void

// MARK: - Body
Expand Down
Loading

0 comments on commit 548d35b

Please sign in to comment.