Static Notification Payloads, Move more to `IdentifiedArray` (#1349)

* wip

* wip

* wip

* wip

* clean up

* clean up

* Update VideoPlayerManager.swift

* clean up
This commit is contained in:
Ethan Pippin 2024-12-08 23:57:16 -07:00 committed by GitHub
parent e856303181
commit c8acd780be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 376 additions and 269 deletions

View File

@ -8,28 +8,23 @@
import SwiftUI
struct RedrawOnNotificationView<Content: View>: View {
struct RedrawOnNotificationView<Content: View, P>: View {
@State
private var id = 0
private let name: NSNotification.Name
private let key: Notifications.Key<P>
private let content: () -> Content
init(name: NSNotification.Name, @ViewBuilder content: @escaping () -> Content) {
self.name = name
self.content = content
}
init(_ swiftfinNotification: Notifications.Key, @ViewBuilder content: @escaping () -> Content) {
self.name = swiftfinNotification.underlyingNotification.name
init(_ key: Notifications.Key<P>, @ViewBuilder content: @escaping () -> Content) {
self.key = key
self.content = content
}
var body: some View {
content()
.id(id)
.onNotification(name) { _ in
.onNotification(key) { _ in
id += 1
}
}

View File

@ -8,15 +8,13 @@
import SwiftUI
struct OnReceiveNotificationModifier: ViewModifier {
struct OnReceiveNotificationModifier<P, K: Notifications.Key<P>>: ViewModifier {
let notification: NSNotification.Name
let onReceive: (Notification) -> Void
let key: K
let onReceive: (P) -> Void
func body(content: Content) -> some View {
content
.onReceive(NotificationCenter.default.publisher(for: notification)) {
onReceive($0)
}
.onReceive(key.publisher, perform: onReceive)
}
}

View File

@ -314,19 +314,10 @@ extension View {
}
}
func onNotification(_ name: NSNotification.Name, perform action: @escaping (Notification) -> Void) -> some View {
func onNotification<P>(_ key: Notifications.Key<P>, perform action: @escaping (P) -> Void) -> some View {
modifier(
OnReceiveNotificationModifier(
notification: name,
onReceive: action
)
)
}
func onNotification(_ swiftfinNotification: Notifications.Key, perform action: @escaping (Notification) -> Void) -> some View {
modifier(
OnReceiveNotificationModifier(
notification: swiftfinNotification.underlyingNotification.name,
key: key,
onReceive: action
)
)

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
/// A container for `Notifications.Key`.
struct NotificationSet {
private var names: Set<String> = []
func contains<P>(_ key: Notifications.Key<P>) -> Bool {
names.contains(key.name.rawValue)
}
mutating func insert<P>(_ key: Notifications.Key<P>) {
names.insert(key.name.rawValue)
}
mutating func remove<P>(_ key: Notifications.Key<P>) {
names.remove(key.name.rawValue)
}
}

View File

@ -0,0 +1,184 @@
//
// 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 Factory
import Foundation
import JellyfinAPI
import UIKit
extension Container {
var notificationCenter: Factory<NotificationCenter> {
self { NotificationCenter.default }.singleton
}
}
enum Notifications {
typealias Keys = _AnyKey
class _AnyKey {
typealias Key = Notifications.Key
}
final class Key<Payload>: _AnyKey {
@Injected(\.notificationCenter)
private var notificationCenter
let name: Notification.Name
var rawValue: String {
name.rawValue
}
init(_ string: String) {
self.name = Notification.Name(string)
}
init(_ name: Notification.Name) {
self.name = name
}
func post(_ payload: Payload) {
notificationCenter
.post(
name: name,
object: nil,
userInfo: ["payload": payload]
)
}
func post() where Payload == Void {
notificationCenter
.post(
name: name,
object: nil,
userInfo: nil
)
}
var publisher: AnyPublisher<Payload, Never> {
notificationCenter
.publisher(for: name)
.compactMap { notification in
notification.userInfo?["payload"] as? Payload
}
.eraseToAnyPublisher()
}
func subscribe(_ object: Any, selector: Selector) {
notificationCenter.addObserver(object, selector: selector, name: name, object: nil)
}
}
static subscript<Payload>(key: Key<Payload>) -> Key<Payload> {
key
}
}
// MARK: - Keys
extension Notifications.Key {
// MARK: - Authentication
static var didSignIn: Key<Void> {
Key("didSignIn")
}
static var didSignOut: Key<Void> {
Key("didSignOut")
}
// MARK: - App Flow
static var processDeepLink: Key<Void> {
Key("processDeepLink")
}
static var didPurge: Key<Void> {
Key("didPurge")
}
static var didChangeCurrentServerURL: Key<ServerState> {
Key("didChangeCurrentServerURL")
}
static var didSendStopReport: Key<Void> {
Key("didSendStopReport")
}
static var didRequestGlobalRefresh: Key<Void> {
Key("didRequestGlobalRefresh")
}
static var didFailMigration: Key<Void> {
Key("didFailMigration")
}
// MARK: - Media Items
/// - Payload: The new item with updated metadata.
static var itemMetadataDidChange: Key<BaseItemDto> {
Key("itemMetadataDidChange")
}
static var itemShouldRefresh: Key<(itemID: String, parentID: String?)> {
Key("itemShouldRefresh")
}
/// - Payload: The ID of the deleted item.
static var didDeleteItem: Key<String> {
Key("didDeleteItem")
}
// MARK: - Server
static var didConnectToServer: Key<ServerState> {
Key("didConnectToServer")
}
static var didDeleteServer: Key<ServerState> {
Key("didDeleteServer")
}
// MARK: - User
static var didChangeUserProfileImage: Key<Void> {
Key("didChangeUserProfileImage")
}
static var didAddServerUser: Key<UserDto> {
Key("didAddServerUser")
}
// MARK: - Playback
static var didStartPlayback: Key<Void> {
Key("didStartPlayback")
}
// MARK: - UIApplication
static var applicationDidEnterBackground: Key<Void> {
Key(UIApplication.didEnterBackgroundNotification)
}
static var applicationWillEnterForeground: Key<Void> {
Key(UIApplication.willEnterForegroundNotification)
}
static var applicationWillResignActive: Key<Void> {
Key(UIApplication.willResignActiveNotification)
}
static var applicationWillTerminate: Key<Void> {
Key(UIApplication.willTerminateNotification)
}
}

View File

@ -1,94 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Factory
import Foundation
class SwiftfinNotification {
@Injected(\.notificationCenter)
private var notificationService
let name: Notification.Name
fileprivate init(_ notificationName: Notification.Name) {
self.name = notificationName
}
func post(object: Any? = nil) {
notificationService.post(name: name, object: object)
}
func subscribe(_ observer: Any, selector: Selector) {
notificationService.addObserver(observer, selector: selector, name: name, object: nil)
}
func unsubscribe(_ observer: Any) {
notificationService.removeObserver(self, name: name, object: nil)
}
var publisher: NotificationCenter.Publisher {
notificationService.publisher(for: name)
}
}
extension Container {
var notificationCenter: Factory<NotificationCenter> { self { NotificationCenter.default }.singleton }
}
enum Notifications {
struct Key: Hashable {
static func == (lhs: Notifications.Key, rhs: Notifications.Key) -> Bool {
lhs.key == rhs.key
}
func hash(into hasher: inout Hasher) {
hasher.combine(key)
}
typealias NotificationKey = Notifications.Key
let key: String
let underlyingNotification: SwiftfinNotification
init(_ key: String) {
self.key = key
self.underlyingNotification = SwiftfinNotification(Notification.Name(key))
}
}
static subscript(key: Key) -> SwiftfinNotification {
key.underlyingNotification
}
}
extension Notifications.Key {
static let didSignIn = NotificationKey("didSignIn")
static let didSignOut = NotificationKey("didSignOut")
static let processDeepLink = NotificationKey("processDeepLink")
static let didPurge = NotificationKey("didPurge")
static let didChangeCurrentServerURL = NotificationKey("didChangeCurrentServerURL")
static let didSendStopReport = NotificationKey("didSendStopReport")
static let didRequestGlobalRefresh = NotificationKey("didRequestGlobalRefresh")
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")
static let didChangeUserProfileImage = NotificationKey("didChangeUserProfileImage")
static let didStartPlayback = NotificationKey("didStartPlayback")
static let didAddServerUser = NotificationKey("didStartPlayback")
}

View File

@ -233,7 +233,7 @@ final class ConnectToServerViewModel: ViewModel, Eventful, Stateful {
return editServer.state
}
Notifications[.didChangeCurrentServerURL].post(object: newState)
Notifications[.didChangeCurrentServerURL].post(newState)
} catch {
logger.critical("\(error.localizedDescription)")
}

View File

@ -54,7 +54,7 @@ final class HomeViewModel: ViewModel, Stateful {
// TODO: replace with views checking what notifications were
// posted since last disappear
@Published
var notificationsReceived: Set<Notifications.Key> = []
var notificationsReceived: NotificationSet = .init()
private var backgroundRefreshTask: AnyCancellable?
private var refreshTask: AnyCancellable?
@ -65,13 +65,14 @@ final class HomeViewModel: ViewModel, Stateful {
override init() {
super.init()
Notifications[.itemMetadataDidChange].publisher
Notifications[.itemMetadataDidChange]
.publisher
.sink { _ in
// Necessary because when this notification is posted, even with asyncAfter,
// the view will cause layout issues since it will redraw while in landscape.
// TODO: look for better solution
DispatchQueue.main.async {
self.notificationsReceived.insert(Notifications.Key.itemMetadataDidChange)
self.notificationsReceived.insert(.itemMetadataDidChange)
}
}
.store(in: &cancellables)

View File

@ -96,7 +96,7 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful {
// MARK: Metadata Refresh Logic
private func deleteItem() async throws {
guard let itemID = item?.id else {
guard let item, let itemID = item.id else {
throw JellyfinAPIError(L10n.unknownError)
}
@ -104,7 +104,7 @@ class DeleteItemViewModel: ViewModel, Stateful, Eventful {
_ = try await userSession.client.send(request)
await MainActor.run {
Notifications[.didDeleteItem].post(object: item)
Notifications[.didDeleteItem].post(itemID)
self.item = nil
}
}

View File

@ -237,7 +237,7 @@ class ItemEditorViewModel<Element: Equatable>: ViewModel, Stateful, Eventful {
try await refreshItem()
await MainActor.run {
Notifications[.itemMetadataDidChange].post(object: newItem)
Notifications[.itemMetadataDidChange].post(newItem)
}
}

View File

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

View File

@ -89,23 +89,24 @@ class ItemViewModel: ViewModel, Stateful {
self.item = item
super.init()
// TODO: should replace with a more robust "PlaybackManager"
Notifications[.itemMetadataDidChange].publisher
.sink { [weak self] notification in
if let userInfo = notification.object as? [String: String] {
if let itemID = userInfo["itemID"], itemID == item.id {
Task { [weak self] in
await self?.send(.backgroundRefresh)
}
} else if let seriesID = userInfo["seriesID"], seriesID == item.id {
Task { [weak self] in
await self?.send(.backgroundRefresh)
Notifications[.itemShouldRefresh]
.publisher
.sink { itemID, parentID in
guard itemID == self.item.id || parentID == self.item.id else { return }
Task {
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)
Notifications[.itemMetadataDidChange]
.publisher
.sink { newItem in
guard let newItemID = newItem.id, newItemID == self.item.id else { return }
Task {
await self.send(.replace(newItem))
}
}
.store(in: &cancellables)
@ -314,6 +315,8 @@ class ItemViewModel: ViewModel, Stateful {
private func setIsPlayed(_ isPlayed: Bool) async throws {
guard let itemID = item.id else { return }
let request: Request<UserItemDataDto>
if isPlayed {
@ -329,9 +332,7 @@ class ItemViewModel: ViewModel, Stateful {
}
let _ = try await userSession.client.send(request)
let ids = ["itemID": item.id]
Notifications[.itemMetadataDidChange].post(object: ids)
Notifications[.itemShouldRefresh].post((itemID, nil))
}
private func setIsFavorite(_ isFavorite: Bool) async throws {

View File

@ -13,10 +13,14 @@ import JellyfinAPI
// Since we don't view care to view seasons directly, this doesn't subclass from `ItemViewModel`.
// If we ever care for viewing seasons directly, subclass from that and have the library view model
// as a property.
final class SeasonItemViewModel: PagingLibraryViewModel<BaseItemDto> {
final class SeasonItemViewModel: PagingLibraryViewModel<BaseItemDto>, Identifiable {
let season: BaseItemDto
var id: String? {
season.id
}
init(season: BaseItemDto) {
self.season = season
super.init(parent: season)
@ -43,14 +47,3 @@ final class SeasonItemViewModel: PagingLibraryViewModel<BaseItemDto> {
return response.value.items ?? []
}
}
extension SeasonItemViewModel: Hashable {
static func == (lhs: SeasonItemViewModel, rhs: SeasonItemViewModel) -> Bool {
lhs.parent as! BaseItemDto == rhs.parent as! BaseItemDto
}
func hash(into hasher: inout Hasher) {
hasher.combine((parent as! BaseItemDto).hashValue)
}
}

View File

@ -10,8 +10,8 @@ import Combine
import Defaults
import Factory
import Foundation
import IdentifiedCollections
import JellyfinAPI
import OrderedCollections
// TODO: care for one long episodes list?
// - after SeasonItemViewModel is bidirectional
@ -19,7 +19,7 @@ import OrderedCollections
final class SeriesItemViewModel: ItemViewModel {
@Published
var seasons: OrderedSet<SeasonItemViewModel> = []
var seasons: IdentifiedArrayOf<SeasonItemViewModel> = []
override func onRefresh() async throws {

View File

@ -67,8 +67,9 @@ class PagingLibraryViewModel<Element: Poster & Identifiable>: ViewModel, Eventfu
@Published
final var backgroundStates: OrderedSet<BackgroundState> = []
/// - Keys: the `hashValue` of the `Element.ID`
@Published
final var elements: IdentifiedArrayOf<Element>
final var elements: IdentifiedArray<Int, Element>
@Published
final var state: State = .initial
@Published
@ -104,7 +105,7 @@ class PagingLibraryViewModel<Element: Poster & Identifiable>: ViewModel, Eventfu
parent: (any LibraryParent)? = nil
) {
self.filterViewModel = nil
self.elements = IdentifiedArray(uniqueElements: data)
self.elements = IdentifiedArray(uniqueElements: data, id: \.id.hashValue)
self.isStatic = true
self.hasNextPage = false
self.pageSize = DefaultPageSize
@ -131,7 +132,7 @@ class PagingLibraryViewModel<Element: Poster & Identifiable>: ViewModel, Eventfu
filters: ItemFilterCollection? = nil,
pageSize: Int = DefaultPageSize
) {
self.elements = IdentifiedArray()
self.elements = IdentifiedArray(id: \.id.hashValue)
self.isStatic = false
self.pageSize = pageSize
self.parent = parent
@ -160,10 +161,10 @@ class PagingLibraryViewModel<Element: Poster & Identifiable>: ViewModel, Eventfu
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)
Notifications[.didDeleteItem]
.publisher
.sink { id in
self.elements.remove(id: id.hashValue)
}
.store(in: &cancellables)

View File

@ -50,7 +50,7 @@ class ServerConnectionViewModel: ViewModel {
UserDefaults.userSuite(id: user.id).removeAll()
}
Notifications[.didDeleteServer].post(object: server)
Notifications[.didDeleteServer].post(server)
} catch {
logger.critical("Unable to delete server: \(server.name)")
}
@ -67,7 +67,7 @@ class ServerConnectionViewModel: ViewModel {
return storedServer.state
}
Notifications[.didChangeCurrentServerURL].post(object: newState)
Notifications[.didChangeCurrentServerURL].post(newState)
self.server = newState
} catch {

View File

@ -213,8 +213,10 @@ class VideoPlayerManager: ViewModel {
func sendStopReport() {
// TODO: This entire system is being redone in other PRs,
// can ignore the fact this is commented out for now.
let ids = ["itemID": currentViewModel.item.id, "seriesID": currentViewModel.item.parentID]
Notifications[.itemMetadataDidChange].post(object: ids)
// Notifications[.itemMetadataDidChange].post(ids)
#if DEBUG
guard Defaults[.sendProgressReports] else { return }

View File

@ -58,10 +58,10 @@ struct SwiftfinApp: App {
WindowGroup {
MainCoordinator()
.view()
.onNotification(UIApplication.didEnterBackgroundNotification) { _ in
.onNotification(.applicationDidEnterBackground) {
Defaults[.backgroundTimeStamp] = Date.now
}
.onNotification(UIApplication.willEnterForegroundNotification) { _ in
.onNotification(.applicationWillEnterForeground) {
// TODO: needs to check if any background playback is happening
let backgroundedInterval = Date.now.timeIntervalSince(Defaults[.backgroundTimeStamp])

View File

@ -153,7 +153,7 @@ struct ConnectToServerView: View {
.onReceive(viewModel.events) { event in
switch event {
case let .connected(server):
Notifications[.didConnectToServer].post(object: server)
Notifications[.didConnectToServer].post(server)
router.popLast()
case let .duplicateServer(server):
duplicateServer = server

View File

@ -79,8 +79,8 @@ extension SeriesEpisodeSelector {
onContentFocus: { focusedEpisodeID = lastFocusedEpisodeID },
top: "seasons"
)
.onChange(of: viewModel) { _, newValue in
lastFocusedEpisodeID = newValue.elements.first?.id
.onChange(of: viewModel.id) {
lastFocusedEpisodeID = viewModel.elements.first?.id
}
.onChange(of: focusedEpisodeID) { _, newValue in
guard let newValue else { return }

View File

@ -21,15 +21,19 @@ struct SeriesEpisodeSelector: View {
@State
private var didSelectPlayButtonSeason = false
@State
private var selection: SeasonItemViewModel?
private var selection: SeasonItemViewModel.ID?
private var selectionViewModel: SeasonItemViewModel? {
viewModel.seasons.first(where: { $0.id == selection })
}
var body: some View {
VStack(spacing: 0) {
SeasonsHStack(viewModel: viewModel, selection: $selection)
.environmentObject(parentFocusGuide)
if let selection {
EpisodeHStack(viewModel: selection, playButtonItem: viewModel.playButtonItem)
if let selectionViewModel {
EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem)
.environmentObject(parentFocusGuide)
} else {
LoadingHStack()
@ -40,17 +44,17 @@ struct SeriesEpisodeSelector: View {
guard !didSelectPlayButtonSeason else { return }
didSelectPlayButtonSeason = true
if let season = viewModel.seasons.first(where: { $0.season.id == newValue.seasonID }) {
selection = season
if let playButtonSeason = viewModel.seasons.first(where: { $0.id == newValue.seasonID }) {
selection = playButtonSeason.id
} else {
selection = viewModel.seasons.first
selection = viewModel.seasons.first?.id
}
}
.onChange(of: selection) { _, newValue in
guard let newValue else { return }
.onChange(of: selection) { _ in
guard let selectionViewModel else { return }
if newValue.state == .initial {
newValue.send(.refresh)
if selectionViewModel.state == .initial {
selectionViewModel.send(.refresh)
}
}
}
@ -66,31 +70,31 @@ extension SeriesEpisodeSelector {
private var focusGuide: FocusGuide
@FocusState
private var focusedSeason: SeasonItemViewModel?
private var focusedSeason: SeasonItemViewModel.ID?
@ObservedObject
var viewModel: SeriesItemViewModel
var selection: Binding<SeasonItemViewModel?>
var selection: Binding<SeasonItemViewModel.ID?>
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(viewModel.seasons, id: \.season.id) { seasonViewModel in
ForEach(viewModel.seasons) { seasonViewModel in
Button {
Text(seasonViewModel.season.displayTitle)
.font(.headline)
.fontWeight(.semibold)
.padding(.vertical, 10)
.padding(.horizontal, 20)
.if(selection.wrappedValue == seasonViewModel) { text in
.if(selection.wrappedValue == seasonViewModel.id) { text in
text
.background(Color.white)
.foregroundColor(.black)
}
}
.buttonStyle(.card)
.focused($focusedSeason, equals: seasonViewModel)
.focused($focusedSeason, equals: seasonViewModel.id)
}
}
.focusGuide(

View File

@ -310,22 +310,17 @@ struct SelectUserView: View {
Notifications[.didSignIn].post()
}
}
.onNotification(.didConnectToServer) { notification in
if let server = notification.object as? ServerState {
.onNotification(.didConnectToServer) { server in
viewModel.send(.getServers)
serverSelection = .server(id: server.id)
}
}
.onNotification(.didChangeCurrentServerURL) { notification in
if let server = notification.object as? ServerState {
.onNotification(.didChangeCurrentServerURL) { server in
viewModel.send(.getServers)
serverSelection = .server(id: server.id)
}
}
.onNotification(.didDeleteServer) { notification in
.onNotification(.didDeleteServer) { server in
viewModel.send(.getServers)
if let server = notification.object as? ServerState {
if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id {
if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first {
serverSelection = .server(id: first.id)
@ -338,5 +333,4 @@ struct SelectUserView: View {
// selectUserAllServersSplashscreen = serverSelection
}
}
}
}

View File

@ -545,6 +545,8 @@
E13AF3B828A0C598009093AB /* NukeExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B728A0C598009093AB /* NukeExtensions */; };
E13AF3BA28A0C598009093AB /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3B928A0C598009093AB /* NukeUI */; };
E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */ = {isa = PBXBuildFile; productRef = E13AF3BB28A0C59E009093AB /* BlurHashKit */; };
E13D98ED2D0664C1005FE96D /* NotificationSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13D98EC2D0664C1005FE96D /* NotificationSet.swift */; };
E13D98EE2D0664C1005FE96D /* NotificationSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13D98EC2D0664C1005FE96D /* NotificationSet.swift */; };
E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */; };
E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3C52716499E009D4DAF /* CoreStore */; };
E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3C727164B1E009D4DAF /* UIDevice.swift */; };
@ -623,8 +625,8 @@
E1549663296CA2EF00C4EF88 /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549657296CA2EF00C4EF88 /* UserSession.swift */; };
E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */; };
E1549665296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */; };
E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */; };
E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */; };
E1549666296CA2EF00C4EF88 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* Notifications.swift */; };
E1549667296CA2EF00C4EF88 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549659296CA2EF00C4EF88 /* Notifications.swift */; };
E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965B296CA2EF00C4EF88 /* DownloadManager.swift */; };
E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965B296CA2EF00C4EF88 /* DownloadManager.swift */; };
E154966E296CA2EF00C4EF88 /* LogManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E154965D296CA2EF00C4EF88 /* LogManager.swift */; };
@ -1560,6 +1562,7 @@
E139CC1C28EC836F00688DE2 /* ChapterOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterOverlay.swift; sourceTree = "<group>"; };
E139CC1E28EC83E400688DE2 /* Int.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Int.swift; sourceTree = "<group>"; };
E13D02842788B634000FCB04 /* Swiftfin.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Swiftfin.entitlements; sourceTree = "<group>"; };
E13D98EC2D0664C1005FE96D /* NotificationSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSet.swift; sourceTree = "<group>"; };
E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
E13DD3C727164B1E009D4DAF /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = "<group>"; };
E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSignInViewModel.swift; sourceTree = "<group>"; };
@ -1605,7 +1608,7 @@
E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinDefaults.swift; sourceTree = "<group>"; };
E1549657296CA2EF00C4EF88 /* UserSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = "<group>"; };
E1549658296CA2EF00C4EF88 /* SwiftfinStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinStore.swift; sourceTree = "<group>"; };
E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftfinNotifications.swift; sourceTree = "<group>"; };
E1549659296CA2EF00C4EF88 /* Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = "<group>"; };
E154965B296CA2EF00C4EF88 /* DownloadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
E154965D296CA2EF00C4EF88 /* LogManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogManager.swift; sourceTree = "<group>"; };
E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineEnumToggle.swift; sourceTree = "<group>"; };
@ -2777,6 +2780,7 @@
E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */,
E1DE2B4E2B983F3200F6715F /* LibraryParent */,
4E2AC4C02C6C48EB00DD600D /* MediaComponents */,
E13D98EC2D0664C1005FE96D /* NotificationSet.swift */,
E1AA331E2782639D00F6439C /* OverlayType.swift */,
E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */,
4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */,
@ -3747,8 +3751,8 @@
E1549655296CA2EF00C4EF88 /* DownloadTask.swift */,
E19D41A92BF077130082B8B2 /* Keychain.swift */,
E154965D296CA2EF00C4EF88 /* LogManager.swift */,
E1549659296CA2EF00C4EF88 /* Notifications.swift */,
E1549656296CA2EF00C4EF88 /* SwiftfinDefaults.swift */,
E1549659296CA2EF00C4EF88 /* SwiftfinNotifications.swift */,
E1549657296CA2EF00C4EF88 /* UserSession.swift */,
);
path = Services;
@ -4951,6 +4955,7 @@
E146A9D92BE6E9830034DA1E /* StoredValue.swift in Sources */,
E13DD3FA2717E961009D4DAF /* SelectUserViewModel.swift in Sources */,
C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */,
E13D98EE2D0664C1005FE96D /* NotificationSet.swift in Sources */,
E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */,
4E6619FC2CEFE2BE00025C99 /* ItemEditorViewModel.swift in Sources */,
E1CB757F2C80F28F00217C76 /* SubtitleProfile.swift in Sources */,
@ -5070,7 +5075,7 @@
62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */,
4E2182E52CAF67F50094806B /* PlayMethod.swift in Sources */,
62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */,
E1549667296CA2EF00C4EF88 /* Notifications.swift in Sources */,
E150C0BB2BFD44F500944FFA /* ImagePipeline.swift in Sources */,
E11E0E8D2BF7E76F007676DD /* DataCache.swift in Sources */,
E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */,
@ -5326,6 +5331,7 @@
E17FB55528C1250B00311DFE /* SimilarItemsHStack.swift in Sources */,
C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */,
5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */,
E13D98ED2D0664C1005FE96D /* NotificationSet.swift in Sources */,
E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */,
4E5071D72CFCEB75003FA2AD /* TagEditorViewModel.swift in Sources */,
E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */,
@ -5622,7 +5628,7 @@
E1E0BEB729EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */,
E1FA891E289A305D00176FEB /* iPadOSCollectionItemContentView.swift in Sources */,
E12CC1AE28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */,
E1549666296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */,
E1549666296CA2EF00C4EF88 /* Notifications.swift in Sources */,
4EE141692C8BABDF0045B661 /* ActiveSessionProgressSection.swift in Sources */,
E1A1528528FD191A00600579 /* TextPair.swift in Sources */,
6334175D287DE0D0000603CE /* QuickConnectAuthorizeViewModel.swift in Sources */,
@ -6228,7 +6234,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 78;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = TY84JMYEFE;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -6244,7 +6250,7 @@
);
MARKETING_VERSION = 1.0.0;
OTHER_CFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin;
PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = NO;
@ -6268,7 +6274,7 @@
CURRENT_PROJECT_VERSION = 78;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
DEVELOPMENT_TEAM = TY84JMYEFE;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -6284,7 +6290,7 @@
);
MARKETING_VERSION = 1.0.0;
OTHER_CFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = org.jellyfin.swiftfin;
PRODUCT_BUNDLE_IDENTIFIER = pip.jellyfin.swiftfin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTS_MACCATALYST = NO;

View File

@ -97,10 +97,10 @@ struct SwiftfinApp: App {
WindowGroup {
versionedView
.ignoresSafeArea()
.onNotification(UIApplication.didEnterBackgroundNotification) { _ in
.onNotification(.applicationDidEnterBackground) {
Defaults[.backgroundTimeStamp] = Date.now
}
.onNotification(UIApplication.willEnterForegroundNotification) { _ in
.onNotification(.applicationWillEnterForeground) {
// TODO: needs to check if any background playback is happening
// - atow, background video playback isn't officially supported

View File

@ -55,15 +55,21 @@ extension View {
}
func onAppDidEnterBackground(_ action: @escaping () -> Void) -> some View {
onNotification(UIApplication.didEnterBackgroundNotification, perform: { _ in action() })
onNotification(.applicationDidEnterBackground) {
action()
}
}
func onAppWillResignActive(_ action: @escaping () -> Void) -> some View {
onNotification(UIApplication.willResignActiveNotification, perform: { _ in action() })
onNotification(.applicationWillResignActive) { _ in
action()
}
}
func onAppWillTerminate(_ action: @escaping () -> Void) -> some View {
onNotification(UIApplication.willTerminateNotification, perform: { _ in action() })
onNotification(.applicationWillTerminate) { _ in
action()
}
}
@ViewBuilder

View File

@ -81,7 +81,8 @@ extension AppURLHandler {
// It would be nice if the ItemViewModel could be initialized to id later.
getItem(userID: userID, itemID: itemID) { item in
guard let item = item else { return }
Notifications[.processDeepLink].post(object: DeepLink.item(item))
// TODO: reimplement URL handling
// Notifications[.processDeepLink].post(DeepLink.item(item))
}
return true

View File

@ -115,7 +115,7 @@ struct AddServerUserView: View {
UIDevice.feedback(.success)
router.dismissCoordinator {
Notifications[.didAddServerUser].post(object: newUser)
Notifications[.didAddServerUser].post(newUser)
}
}
}

View File

@ -147,8 +147,7 @@ struct ServerUsersView: View {
} message: {
Text(L10n.deleteUserSelfDeletion(viewModel.userSession.user.username))
}
.onNotification(.didAddServerUser) { notification in
let newUser = notification.object as! UserDto
.onNotification(.didAddServerUser) { newUser in
viewModel.send(.appendUser(newUser))
router.route(to: \.userDetails, newUser)
}

View File

@ -42,7 +42,7 @@ struct ConnectToServerView: View {
case let .connected(server):
UIDevice.feedback(.success)
Notifications[.didConnectToServer].post(object: server)
Notifications[.didConnectToServer].post(server)
router.popLast()
case let .duplicateServer(server):
UIDevice.feedback(.warning)

View File

@ -20,16 +20,20 @@ struct SeriesEpisodeSelector: View {
@State
private var didSelectPlayButtonSeason = false
@State
private var selection: SeasonItemViewModel?
private var selection: SeasonItemViewModel.ID?
private var selectionViewModel: SeasonItemViewModel? {
viewModel.seasons.first(where: { $0.id == selection })
}
@ViewBuilder
private var seasonSelectorMenu: some View {
Menu {
ForEach(viewModel.seasons, id: \.season.id) { seasonViewModel in
Button {
selection = seasonViewModel
selection = seasonViewModel.id
} label: {
if seasonViewModel == selection {
if seasonViewModel.id == selection {
Label(seasonViewModel.season.displayTitle, systemImage: "checkmark")
} else {
Text(seasonViewModel.season.displayTitle)
@ -38,7 +42,7 @@ struct SeriesEpisodeSelector: View {
}
} label: {
Label(
selection?.season.displayTitle ?? .emptyDash,
selectionViewModel?.season.displayTitle ?? .emptyDash,
systemImage: "chevron.down"
)
.labelStyle(.episodeSelector)
@ -52,8 +56,8 @@ struct SeriesEpisodeSelector: View {
.edgePadding([.bottom, .horizontal])
Group {
if let selection {
EpisodeHStack(viewModel: selection, playButtonItem: viewModel.playButtonItem)
if let selectionViewModel {
EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem)
} else {
LoadingHStack()
}
@ -65,17 +69,17 @@ struct SeriesEpisodeSelector: View {
guard !didSelectPlayButtonSeason else { return }
didSelectPlayButtonSeason = true
if let season = viewModel.seasons.first(where: { $0.season.id == newValue.seasonID }) {
selection = season
if let playButtonSeason = viewModel.seasons.first(where: { $0.id == newValue.seasonID }) {
selection = playButtonSeason.id
} else {
selection = viewModel.seasons.first
selection = viewModel.seasons.first?.id
}
}
.onChange(of: selection) { newValue in
guard let newValue else { return }
.onChange(of: selection) { _ in
guard let selectionViewModel else { return }
if newValue.state == .initial {
newValue.send(.refresh)
if selectionViewModel.state == .initial {
selectionViewModel.send(.refresh)
}
}
}

View File

@ -566,22 +566,17 @@ struct SelectUserView: View {
Notifications[.didSignIn].post()
}
}
.onNotification(.didConnectToServer) { notification in
if let server = notification.object as? ServerState {
.onNotification(.didConnectToServer) { server in
viewModel.send(.getServers)
serverSelection = .server(id: server.id)
}
}
.onNotification(.didChangeCurrentServerURL) { notification in
if let server = notification.object as? ServerState {
.onNotification(.didChangeCurrentServerURL) { server in
viewModel.send(.getServers)
serverSelection = .server(id: server.id)
}
}
.onNotification(.didDeleteServer) { notification in
.onNotification(.didDeleteServer) { server in
viewModel.send(.getServers)
if let server = notification.object as? ServerState {
if case let SelectUserServerSelection.server(id: id) = serverSelection, server.id == id {
if viewModel.servers.keys.count == 1, let first = viewModel.servers.keys.first {
serverSelection = .server(id: first.id)
@ -593,7 +588,6 @@ struct SelectUserView: View {
// change splash screen selection if necessary
selectUserAllServersSplashscreen = serverSelection
}
}
.alert(
Text("Delete User"),
isPresented: $isPresentingConfirmDeleteUsers,

View File

@ -28,7 +28,7 @@ struct UserProfileSettingsView: View {
@ViewBuilder
private var imageView: some View {
RedrawOnNotificationView(name: .init("didChangeUserProfileImage")) {
RedrawOnNotificationView(.didChangeUserProfileImage) {
ImageView(
viewModel.userSession.user.profileImageSource(
client: viewModel.userSession.client,