diff --git a/Shared/Components/ImageView.swift b/Shared/Components/ImageView.swift index fb1313c8..48fc7c8e 100644 --- a/Shared/Components/ImageView.swift +++ b/Shared/Components/ImageView.swift @@ -42,8 +42,8 @@ struct ImageView: View { if state.isLoading { _placeholder(currentSource) } else if let _image = state.image { - _image - .resizable() + image(_image.resizable()) + .eraseToAnyView() } else if state.error != nil { failure() .eraseToAnyView() @@ -128,6 +128,7 @@ extension ImageView { var body: some View { Color.secondarySystemFill + .opacity(0.75) } } @@ -140,6 +141,7 @@ extension ImageView { BlurHashView(blurHash: blurHash, size: .Square(length: 8)) } else { Color.secondarySystemFill + .opacity(0.75) } } } diff --git a/Shared/Components/TypeSystemNameView.swift b/Shared/Components/SystemImageContentView.swift similarity index 100% rename from Shared/Components/TypeSystemNameView.swift rename to Shared/Components/SystemImageContentView.swift diff --git a/Shared/Extensions/EdgeInsets.swift b/Shared/Extensions/EdgeInsets.swift index d78bd9dd..eada39ac 100644 --- a/Shared/Extensions/EdgeInsets.swift +++ b/Shared/Extensions/EdgeInsets.swift @@ -34,6 +34,8 @@ extension EdgeInsets { init(vertical: CGFloat = 0, horizontal: CGFloat = 0) { self.init(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal) } + + static let zero: EdgeInsets = .init() } extension NSDirectionalEdgeInsets { diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift index 74e4fd40..e89c8100 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift @@ -52,6 +52,8 @@ extension BaseItemDto: Poster { "folder.fill" case .person: "person.fill" + case .boxSet: + "film.stack" default: nil } } diff --git a/Shared/Extensions/UIHostingController.swift b/Shared/Extensions/UIHostingController.swift new file mode 100644 index 00000000..da2258c7 --- /dev/null +++ b/Shared/Extensions/UIHostingController.swift @@ -0,0 +1,47 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension UIHostingController { + + public convenience init(rootView: Content, ignoreSafeArea: Bool) { + self.init(rootView: rootView) + + if ignoreSafeArea { + disableSafeArea() + } + } + + func disableSafeArea() { + guard let viewClass = object_getClass(view) else { return } + + let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea") + if let viewSubclass = NSClassFromString(viewSubclassName) { + object_setClass(view, viewSubclass) + } else { + guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else { return } + guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else { return } + + if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) { + let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in + .zero + } + class_addMethod( + viewSubclass, + #selector(getter: UIView.safeAreaInsets), + imp_implementationWithBlock(safeAreaInsets), + method_getTypeEncoding(method) + ) + } + + objc_registerClassPair(viewSubclass) + object_setClass(view, viewSubclass) + } + } +} diff --git a/Shared/ViewModels/MediaViewModel/MediaType.swift b/Shared/ViewModels/MediaViewModel/MediaType.swift new file mode 100644 index 00000000..462229c0 --- /dev/null +++ b/Shared/ViewModels/MediaViewModel/MediaType.swift @@ -0,0 +1,33 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +extension MediaViewModel { + + enum MediaType: Displayable, Hashable { + case collectionFolder(BaseItemDto) + case downloads + case favorites + case liveTV(BaseItemDto) + + var displayTitle: String { + switch self { + case let .collectionFolder(item): + return item.displayTitle + case .downloads: + return L10n.downloads + case .favorites: + return L10n.favorites + case .liveTV: + return L10n.liveTV + } + } + } +} diff --git a/Shared/ViewModels/MediaViewModel.swift b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift similarity index 86% rename from Shared/ViewModels/MediaViewModel.swift rename to Shared/ViewModels/MediaViewModel/MediaViewModel.swift index 3ddec09d..edea5df0 100644 --- a/Shared/ViewModels/MediaViewModel.swift +++ b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift @@ -16,26 +16,6 @@ final class MediaViewModel: ViewModel, Stateful { // TODO: remove once collection types become an enum static let supportedCollectionTypes: [String] = ["boxsets", "folders", "movies", "tvshows", "livetv"] - enum MediaType: Displayable, Hashable { - case downloads - case favorites - case liveTV - case userView(BaseItemDto) - - var displayTitle: String { - switch self { - case .downloads: - return L10n.downloads - case .favorites: - return L10n.favorites - case .liveTV: - return L10n.liveTV - case let .userView(item): - return item.displayTitle - } - } - } - // MARK: Action enum Action { @@ -90,8 +70,14 @@ final class MediaViewModel: ViewModel, Stateful { mediaItems.removeAll() } - let media = try await getUserViews() - .map(MediaType.userView) + let media: [MediaType] = try await getUserViews() + .map { userView in + if userView.collectionType == "livetv" { + return .liveTV(userView) + } + + return .collectionFolder(userView) + } .prepending(.favorites, if: Defaults[.Customization.Library.showFavorites]) await MainActor.run { @@ -132,9 +118,19 @@ final class MediaViewModel: ViewModel, Stateful { func randomItemImageSources(for mediaType: MediaType) async throws -> [ImageSource] { + // live tv doesn't have random + if case MediaType.liveTV = mediaType { + return [] + } + + // downloads doesn't have random + if mediaType == .downloads { + return [] + } + var parentID: String? - if case let MediaType.userView(item) = mediaType { + if case let MediaType.collectionFolder(item) = mediaType { parentID = item.id } diff --git a/Swiftfin tvOS/Views/MediaView.swift b/Swiftfin tvOS/Views/MediaView.swift index 94fff238..8c4a7234 100644 --- a/Swiftfin tvOS/Views/MediaView.swift +++ b/Swiftfin tvOS/Views/MediaView.swift @@ -30,6 +30,12 @@ struct MediaView: View { MediaItem(viewModel: viewModel, type: mediaType) .onSelect { switch mediaType { + case let .collectionFolder(item): + let viewModel = ItemLibraryViewModel( + parent: item, + filters: .default + ) + router.route(to: \.library, viewModel) case .downloads: () case .favorites: let viewModel = ItemLibraryViewModel( @@ -39,12 +45,6 @@ struct MediaView: View { router.route(to: \.library, viewModel) case .liveTV: mainRouter.root(\.liveTV) - case let .userView(item): - let viewModel = ItemLibraryViewModel( - parent: item, - filters: .default - ) - router.route(to: \.library, viewModel) } } } @@ -101,12 +101,23 @@ extension MediaView { return } - if case let MediaViewModel.MediaType.userView(item) = mediaType { + if case let MediaViewModel.MediaType.collectionFolder(item) = mediaType { + self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + } else if case let MediaViewModel.MediaType.liveTV(item) = mediaType { self.imageSources = [item.imageSource(.primary, maxWidth: 500)] } } } + private var titleLabel: some View { + Text(mediaType.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(1) + .multilineTextAlignment(.center) + .frame(alignment: .center) + } + var body: some View { Button { onSelect() @@ -115,25 +126,32 @@ extension MediaView { Color.clear ImageView(imageSources) - .id(imageSources.hashValue) + .image { image in + if useRandomImage || + mediaType == .downloads || + mediaType == .favorites + { + ZStack { + image - if useRandomImage || - mediaType == .favorites || - mediaType == .downloads - { - ZStack { - Color.black - .opacity(0.5) + Color.black + .opacity(0.5) - Text(mediaType.displayTitle) - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - .lineLimit(1) - .multilineTextAlignment(.center) - .frame(alignment: .center) + titleLabel + .foregroundStyle(.white) + } + } else { + image + } } - } + .failure { + ImageView.DefaultFailureView() + .overlay { + titleLabel + .foregroundColor(.primary) + } + } + .id(imageSources.hashValue) } .posterStyle(.landscape) } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 43514d13..72d794e0 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -63,7 +63,6 @@ 5398514526B64DA100101B49 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5398514426B64DA100101B49 /* SettingsView.swift */; }; 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */; }; 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 53ABFDDB267972BF00886593 /* TVServices.framework */; }; - 53ABFDE4267974EF00886593 /* MediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* MediaViewModel.swift */; }; 53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; }; 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; }; 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */; }; @@ -83,7 +82,6 @@ 6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */ = {isa = PBXBuildFile; productRef = 6220D0C826D63F3700B8E046 /* Stinsen */; }; 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */; }; 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5722678C32A00530A6E /* HomeViewModel.swift */; }; - 625CB5752678C33500530A6E /* MediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* MediaViewModel.swift */; }; 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */; }; 6264E88C273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; 6264E88D273850380081A12A /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6264E88B273850380081A12A /* Strings.swift */; }; @@ -177,7 +175,7 @@ E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; }; E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; }; - E1047E2327E5880000CB0D4A /* TypeSystemNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* TypeSystemNameView.swift */; }; + E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */; }; E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */; }; E104DC902B9D8995008F506D /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E104DC8F2B9D8995008F506D /* CollectionVGrid */; }; @@ -529,7 +527,7 @@ E18E021C2887492B0022598C /* BlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0203288749200022598C /* BlurView.swift */; }; E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E0202288749200022598C /* AttributeStyleModifier.swift */; }; E18E021E2887492B0022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; }; - E18E021F2887492B0022598C /* TypeSystemNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* TypeSystemNameView.swift */; }; + E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; }; E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */; }; @@ -634,6 +632,12 @@ E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92617288756BD002A7A66 /* PosterButton.swift */; }; E1C9261B288756BD002A7A66 /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92618288756BD002A7A66 /* DotHStack.swift */; }; E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92619288756BD002A7A66 /* PosterHStack.swift */; }; + E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF65A2BA345830087D991 /* MediaType.swift */; }; + E1CAF65E2BA345830087D991 /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF65A2BA345830087D991 /* MediaType.swift */; }; + E1CAF65F2BA345830087D991 /* MediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF65B2BA345830087D991 /* MediaViewModel.swift */; }; + E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF65B2BA345830087D991 /* MediaViewModel.swift */; }; + E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF6612BA363840087D991 /* UIHostingController.swift */; }; + E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF6612BA363840087D991 /* UIHostingController.swift */; }; E1CCC3D228C858A50020ED54 /* UserProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */; }; E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */; }; @@ -678,7 +682,6 @@ E1DC9819296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9818296DD1CD00982F06 /* CinematicBackgroundView.swift */; }; E1DC981A296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9818296DD1CD00982F06 /* CinematicBackgroundView.swift */; }; E1DC981E296DD91900982F06 /* CollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = E1DC981D296DD91900982F06 /* CollectionView */; }; - E1DC9821296DDBE600982F06 /* CollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = E1DC9820296DDBE600982F06 /* CollectionView */; }; E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC983C296DEB9B00982F06 /* UnwatchedIndicator.swift */; }; E1DC983E296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC983C296DEB9B00982F06 /* UnwatchedIndicator.swift */; }; E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DC9840296DEBD800982F06 /* WatchedIndicator.swift */; }; @@ -853,7 +856,6 @@ 6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCoordinator.swift; sourceTree = ""; }; 6220D0CB26D640C400B8E046 /* AppURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLHandler.swift; sourceTree = ""; }; 625CB5722678C32A00530A6E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; - 625CB5742678C33500530A6E /* MediaViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewModel.swift; sourceTree = ""; }; 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerViewModel.swift; sourceTree = ""; }; 625CB57B2678CE1000530A6E /* ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModel.swift; sourceTree = ""; }; 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TVVLCKit.xcframework; path = Carthage/Build/TVVLCKit.xcframework; sourceTree = ""; }; @@ -926,7 +928,7 @@ C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = ""; }; - E1047E2227E5880000CB0D4A /* TypeSystemNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeSystemNameView.swift; sourceTree = ""; }; + E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageContentView.swift; sourceTree = ""; }; E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; E104DC952B9E7E29008F506D /* AssertionFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertionFailureView.swift; sourceTree = ""; }; @@ -1200,6 +1202,9 @@ E1C92617288756BD002A7A66 /* PosterButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = ""; }; E1C92618288756BD002A7A66 /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = ""; }; E1C92619288756BD002A7A66 /* PosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; + E1CAF65A2BA345830087D991 /* MediaType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = ""; }; + E1CAF65B2BA345830087D991 /* MediaViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaViewModel.swift; sourceTree = ""; }; + E1CAF6612BA363840087D991 /* UIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIHostingController.swift; sourceTree = ""; }; E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileButton.swift; sourceTree = ""; }; E1CCF12D28ABF989006CAC9E /* PosterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterType.swift; sourceTree = ""; }; E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; @@ -1379,7 +1384,6 @@ E1392FF22BA21B360034110D /* CollectionHStack in Frameworks */, 62666E0E27E501AF00EC0ECD /* Security.framework in Frameworks */, E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */, - E1DC9821296DDBE600982F06 /* CollectionView in Frameworks */, E15EFA842BA167350080E926 /* CollectionHStack in Frameworks */, E15EFA862BA1685F0080E926 /* SwiftUIIntrospect in Frameworks */, 62666DFE27E5015700EC0ECD /* AVFoundation.framework in Frameworks */, @@ -1426,7 +1430,7 @@ E1EDA8D52B924CA500F9A57E /* LibraryViewModel */, C4BE07842728446F003F4AD1 /* LiveTVChannelsViewModel.swift */, C4BE07752725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift */, - 625CB5742678C33500530A6E /* MediaViewModel.swift */, + E1CAF65C2BA345830087D991 /* MediaViewModel */, 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */, 62E632DB267D2E130063E547 /* SearchViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, @@ -1845,6 +1849,7 @@ E1401CB029386C9200E8B599 /* UIColor.swift */, E13DD3C727164B1E009D4DAF /* UIDevice.swift */, E1E0BEB629EF450B0002E8D3 /* UIGestureRecognizer.swift */, + E1CAF6612BA363840087D991 /* UIHostingController.swift */, E1937A3D288F0D3D00CB80AA /* UIScreen.swift */, 62E1DCC2273CE19800C9AE76 /* URL.swift */, E1C812C4277A90B200918266 /* URLComponents.swift */, @@ -2564,9 +2569,9 @@ E18E01FF288749200022598C /* RowDivider.swift */, E1E1643D28BB074000323B0A /* SelectorView.swift */, E1356E0129A7309D00382563 /* SeparatorHStack.swift */, + E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */, E1A1528928FD22F600600579 /* TextPairView.swift */, E1EBCB41278BD174009FE6E9 /* TruncatedText.swift */, - E1047E2227E5880000CB0D4A /* TypeSystemNameView.swift */, E1B5784028F8AFCB00D42911 /* WrappedView.swift */, ); path = Components; @@ -2656,6 +2661,15 @@ path = Components; sourceTree = ""; }; + E1CAF65C2BA345830087D991 /* MediaViewModel */ = { + isa = PBXGroup; + children = ( + E1CAF65A2BA345830087D991 /* MediaType.swift */, + E1CAF65B2BA345830087D991 /* MediaViewModel.swift */, + ); + path = MediaViewModel; + sourceTree = ""; + }; E1D37F502B9CEF1300343D2B /* DeviceProfile */ = { isa = PBXGroup; children = ( @@ -2945,7 +2959,6 @@ E15210572946DF1B00375CC2 /* PulseUI */, E19DDEC62948EF9900954E10 /* OrderedCollections */, E1DC9813296DC06200982F06 /* PulseLogHandler */, - E1DC9820296DDBE600982F06 /* CollectionView */, E1FAD1C52A0375BA007F5521 /* UDPBroadcast */, E14CB6852A9FF62A001586C6 /* JellyfinAPI */, E1523F812B132C350062821A /* CollectionHStack */, @@ -3243,6 +3256,7 @@ E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */, E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */, + E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, @@ -3296,6 +3310,7 @@ E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */, E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */, E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */, + E1CAF65E2BA345830087D991 /* MediaType.swift in Sources */, E12376B02A33D6AE001F5B44 /* AboutViewCard.swift in Sources */, E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */, E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */, @@ -3351,7 +3366,7 @@ E11042762B8013DF00821020 /* Stateful.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, E1575E66293E77B5001665B1 /* Poster.swift in Sources */, - E18E021F2887492B0022598C /* TypeSystemNameView.swift in Sources */, + E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */, E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */, E1575E8C293E7B1E001665B1 /* UIScreen.swift in Sources */, E1575E88293E7A00001665B1 /* LightAppIcon.swift in Sources */, @@ -3438,8 +3453,8 @@ E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */, E1A1528E28FD23AC00600579 /* VideoPlayerSettingsCoordinator.swift in Sources */, E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */, + E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */, E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */, - 53ABFDE4267974EF00886593 /* MediaViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */, E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */, @@ -3572,7 +3587,7 @@ E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, E18E0204288749200022598C /* RowDivider.swift in Sources */, E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, - E1047E2327E5880000CB0D4A /* TypeSystemNameView.swift in Sources */, + E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */, E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */, E18ACA922A15A32F00BB4F35 /* (null) in Sources */, E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */, @@ -3627,6 +3642,7 @@ E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, + E1CAF65F2BA345830087D991 /* MediaViewModel.swift in Sources */, E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, E133328D2953AE4B00EE76AB /* CircularProgressView.swift in Sources */, E12F038C28F8B0B100976CC3 /* EdgeInsets.swift in Sources */, @@ -3661,6 +3677,7 @@ 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, E170D105294D21FA0017224C /* MediaSourceInfoView.swift in Sources */, E1D37F4B2B9CEA5C00343D2B /* ImageSource.swift in Sources */, + E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */, E11895AC289383EE0042947B /* NavBarOffsetModifier.swift in Sources */, E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */, E157563029355B7900976E1F /* UpdateView.swift in Sources */, @@ -3759,6 +3776,7 @@ E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, 535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */, + E1CAF65D2BA345830087D991 /* MediaType.swift in Sources */, E1AD105F26D9ADDD003E4A08 /* NameGuidPair.swift in Sources */, E18A8E7D28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E18E01F1288747230022598C /* PlayButton.swift in Sources */, @@ -3804,7 +3822,6 @@ E1D37F522B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */, E192608028D28AAD002314B4 /* UserProfileButton.swift in Sources */, E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */, - 625CB5752678C33500530A6E /* MediaViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4664,11 +4681,6 @@ isa = XCSwiftPackageProductDependency; productName = CollectionView; }; - E1DC9820296DDBE600982F06 /* CollectionView */ = { - isa = XCSwiftPackageProductDependency; - package = E1DC981F296DDBE600982F06 /* XCRemoteSwiftPackageReference "CollectionView" */; - productName = CollectionView; - }; E1FAD1C52A0375BA007F5521 /* UDPBroadcast */ = { isa = XCSwiftPackageProductDependency; package = E1FAD1C42A0375BA007F5521 /* XCRemoteSwiftPackageReference "UDPBroadcastConnection" */; diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift index 0610c99b..8cfb9bc3 100644 --- a/Swiftfin/Components/PosterButton.swift +++ b/Swiftfin/Components/PosterButton.swift @@ -10,7 +10,7 @@ import Defaults import JellyfinAPI import SwiftUI -// TODO: image aspect fill/fit +// TODO: expose `ImageView.image` modifier for image aspect fill/fit struct PosterButton: View { diff --git a/Swiftfin/Views/DownloadListView.swift b/Swiftfin/Views/DownloadListView.swift index 412b7ebf..70f1d521 100644 --- a/Swiftfin/Views/DownloadListView.swift +++ b/Swiftfin/Views/DownloadListView.swift @@ -6,7 +6,6 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionView import SwiftUI struct DownloadListView: View { diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index 663e3d7b..148e68dd 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -6,7 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionView +import CollectionVGrid import Foundation import JellyfinAPI import SwiftUI @@ -53,41 +53,34 @@ struct LiveTVChannelsView: View { } var body: some View { - - if viewModel.isLoading { - ProgressView() - } else if viewModel.channelPrograms.isNotEmpty { - - CollectionView(items: viewModel.channelPrograms) { _, program, _ in - channelCell(for: program) - } - .layout { _, layoutEnvironment in - .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: .adaptive(withMinItemSize: 250), - itemSpacing: 16, - lineSpacing: 4, - itemSize: .fractionalWidth(1 / 3) - ) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - .onAppear { - viewModel.startScheduleCheckTimer() - } - .onDisappear { - viewModel.stopScheduleCheckTimer() - } - } else { - VStack { - Text(L10n.noResults) - Button { - viewModel.getChannels() - } label: { - Text(L10n.reload) + Group { + if viewModel.isLoading { + ProgressView() + } else if viewModel.channelPrograms.isNotEmpty { + CollectionVGrid( + viewModel.channelPrograms, + layout: .minWidth(250, itemSpacing: 16, lineSpacing: 4) + ) { program in + channelCell(for: program) + } + .onAppear { + viewModel.startScheduleCheckTimer() + } + .onDisappear { + viewModel.stopScheduleCheckTimer() + } + } else { + VStack { + Text(L10n.noResults) + Button { + viewModel.getChannels() + } label: { + Text(L10n.reload) + } } } } + .navigationBarTitleDisplayMode(.inline) } private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { diff --git a/Swiftfin/Views/MediaView.swift b/Swiftfin/Views/MediaView.swift index 597442d9..5bb0c4de 100644 --- a/Swiftfin/Views/MediaView.swift +++ b/Swiftfin/Views/MediaView.swift @@ -40,6 +40,12 @@ struct MediaView: View { MediaItem(viewModel: viewModel, type: mediaType) .onSelect { switch mediaType { + case let .collectionFolder(item): + let viewModel = ItemLibraryViewModel( + parent: item, + filters: .default + ) + router.route(to: \.library, viewModel) case .downloads: router.route(to: \.downloads) case .favorites: @@ -50,12 +56,6 @@ struct MediaView: View { router.route(to: \.library, viewModel) case .liveTV: router.route(to: \.liveTV) - case let .userView(item): - let viewModel = ItemLibraryViewModel( - parent: item, - filters: .default - ) - router.route(to: \.library, viewModel) } } } @@ -98,6 +98,9 @@ struct MediaView: View { extension MediaView { // TODO: custom view for folders and tv (allow customization?) + // - differentiate between what media types are Swiftfin only + // which would allow some cleanup + // - allow server or random view per library? struct MediaItem: View { @Default(.Customization.Library.randomImage) @@ -125,12 +128,23 @@ extension MediaView { return } - if case let MediaViewModel.MediaType.userView(item) = mediaType { + if case let MediaViewModel.MediaType.collectionFolder(item) = mediaType { + self.imageSources = [item.imageSource(.primary, maxWidth: 500)] + } else if case let MediaViewModel.MediaType.liveTV(item) = mediaType { self.imageSources = [item.imageSource(.primary, maxWidth: 500)] } } } + private var titleLabel: some View { + Text(mediaType.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(1) + .multilineTextAlignment(.center) + .frame(alignment: .center) + } + var body: some View { Button { onSelect() @@ -139,25 +153,32 @@ extension MediaView { Color.clear ImageView(imageSources) - .id(imageSources.hashValue) + .image { image in + if useRandomImage || + mediaType == .downloads || + mediaType == .favorites + { + ZStack { + image - if useRandomImage || - mediaType == .favorites || - mediaType == .downloads - { - ZStack { - Color.black - .opacity(0.5) + Color.black + .opacity(0.5) - Text(mediaType.displayTitle) - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - .lineLimit(1) - .multilineTextAlignment(.center) - .frame(alignment: .center) + titleLabel + .foregroundStyle(.white) + } + } else { + image + } } - } + .failure { + ImageView.DefaultFailureView() + .overlay { + titleLabel + .foregroundColor(.primary) + } + } + .id(imageSources.hashValue) } .posterStyle(.landscape) } diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift index fee30f1c..dd202561 100644 --- a/Swiftfin/Views/SearchView.swift +++ b/Swiftfin/Views/SearchView.swift @@ -6,7 +6,6 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionView import Defaults import JellyfinAPI import SwiftUI diff --git a/Swiftfin/Views/UserListView.swift b/Swiftfin/Views/UserListView.swift index 659bc5f4..c6951fc0 100644 --- a/Swiftfin/Views/UserListView.swift +++ b/Swiftfin/Views/UserListView.swift @@ -6,7 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionView +import CollectionVGrid import SwiftUI struct UserListView: View { @@ -34,26 +34,19 @@ struct UserListView: View { @ViewBuilder private var gridView: some View { - CollectionView(items: viewModel.users) { _, user, _ in + CollectionVGrid( + viewModel.users, + layout: .minWidth(120, itemSpacing: 30, lineSpacing: 30) + ) { user in UserProfileButton(user: user, client: viewModel.client) .onSelect { viewModel.signIn(user: user) } - .contextMenu { - Button(role: .destructive) { + .contextMenu(menuItems: { + Button(L10n.remove, systemImage: "trash", role: .destructive) { viewModel.remove(user: user) - } label: { - Label(L10n.remove, systemImage: "trash") } - } - } - .layout { _, layoutEnvironment in - .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: .adaptive(withMinItemSize: 120), - itemSpacing: 30, - lineSpacing: 30 - ) + }) } }