diff --git a/Shared/Components/ImageView.swift b/Shared/Components/ImageView.swift index f5ddff85..062a7d56 100644 --- a/Shared/Components/ImageView.swift +++ b/Shared/Components/ImageView.swift @@ -25,7 +25,6 @@ private let imagePipeline = { // - instead of removing first source on failure, just safe index into sources // TODO: currently SVGs are only supported for logos, which are only used in a few places. // make it so when displaying an SVG there is a unified `image` caller modifier -// TODO: probably don't need both `placeholder` modifiers struct ImageView: View { @State @@ -80,7 +79,7 @@ extension ImageView { sources: [source].compacted(using: \.url), image: { $0 }, placeholder: nil, - failure: { DefaultFailureView() } + failure: { EmptyView() } ) } @@ -89,7 +88,7 @@ extension ImageView { sources: sources.compacted(using: \.url), image: { $0 }, placeholder: nil, - failure: { DefaultFailureView() } + failure: { EmptyView() } ) } @@ -98,7 +97,7 @@ extension ImageView { sources: [ImageSource(url: source)], image: { $0 }, placeholder: nil, - failure: { DefaultFailureView() } + failure: { EmptyView() } ) } @@ -111,7 +110,7 @@ extension ImageView { sources: imageSources, image: { $0 }, placeholder: nil, - failure: { DefaultFailureView() } + failure: { EmptyView() } ) } } @@ -124,10 +123,6 @@ extension ImageView { copy(modifying: \.image, with: content) } - func placeholder(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.placeholder, with: { _ in content() }) - } - func placeholder(@ViewBuilder _ content: @escaping (ImageSource) -> any View) -> Self { copy(modifying: \.placeholder, with: content) } @@ -156,9 +151,6 @@ extension ImageView { var body: some View { if let blurHash { BlurHashView(blurHash: blurHash, size: .Square(length: 8)) - } else { - Color.secondarySystemFill - .opacity(0.75) } } } diff --git a/Shared/Components/SystemImageContentView.swift b/Shared/Components/SystemImageContentView.swift index 001d9023..0662ad89 100644 --- a/Shared/Components/SystemImageContentView.swift +++ b/Shared/Components/SystemImageContentView.swift @@ -14,32 +14,58 @@ struct SystemImageContentView: View { @State private var contentSize: CGSize = .zero + @State + private var labelSize: CGSize = .zero private var backgroundColor: Color private var heightRatio: CGFloat private let systemName: String + private let title: String? private var widthRatio: CGFloat - init(systemName: String?) { + init(title: String? = nil, systemName: String?) { self.backgroundColor = Color.secondarySystemFill self.heightRatio = 3 self.systemName = systemName ?? "circle" + self.title = title self.widthRatio = 3.5 } + private var imageView: some View { + Image(systemName: systemName) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.secondary) + .accessibilityHidden(true) + .frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio) + } + + @ViewBuilder + private var label: some View { + if let title { + Text(title) + .lineLimit(2) + .multilineTextAlignment(.center) + .font(.footnote.weight(.regular)) + .foregroundColor(.secondary) + .trackingSize($labelSize) + } + } + var body: some View { ZStack { backgroundColor .opacity(0.5) - Image(systemName: systemName) - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.secondary) - .accessibilityHidden(true) - .frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio) + imageView + .frame(width: contentSize.width) + .overlay(alignment: .bottom) { + label + .padding(.horizontal, 4) + .offset(y: labelSize.height) + } } - .size($contentSize) + .trackingSize($contentSize) } } diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift index b672ff95..6199e440 100644 --- a/Shared/Coordinators/ItemCoordinator.swift +++ b/Shared/Coordinators/ItemCoordinator.swift @@ -57,7 +57,7 @@ final class ItemCoordinator: NavigationCoordinatable { } func makeCastAndCrew(people: [BaseItemPerson]) -> LibraryCoordinator { - let viewModel = PagingLibraryViewModel(people, parent: BaseItemDto(name: L10n.castAndCrew)) + let viewModel = PagingLibraryViewModel(title: L10n.castAndCrew, people) return LibraryCoordinator(viewModel: viewModel) } diff --git a/Shared/Extensions/Dictionary.swift b/Shared/Extensions/Dictionary.swift new file mode 100644 index 00000000..7364e276 --- /dev/null +++ b/Shared/Extensions/Dictionary.swift @@ -0,0 +1,17 @@ +// +// 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 + +extension Dictionary { + + subscript(key: Key?) -> Value? { + guard let key else { return nil } + return self[key] + } +} diff --git a/Shared/Extensions/EdgeInsets.swift b/Shared/Extensions/EdgeInsets.swift index 0c88fc2f..5e555e1c 100644 --- a/Shared/Extensions/EdgeInsets.swift +++ b/Shared/Extensions/EdgeInsets.swift @@ -10,10 +10,10 @@ import SwiftUI extension EdgeInsets { - // TODO: tvOS - /// The default padding for View's against contextual edges, + // TODO: finalize tvOS + /// The padding for Views against contextual edges, /// typically the edges of the View's scene - static let defaultEdgePadding: CGFloat = { + static let edgePadding: CGFloat = { #if os(tvOS) 50 #else @@ -25,7 +25,7 @@ extension EdgeInsets { #endif }() - static let DefaultEdgeInsets: EdgeInsets = .init(defaultEdgePadding) + static let edgeInsets: EdgeInsets = .init(edgePadding) init(_ constant: CGFloat) { self.init(top: constant, leading: constant, bottom: constant, trailing: constant) diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift index 740843f9..29a348f1 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift @@ -11,28 +11,26 @@ import Foundation import JellyfinAPI import UIKit +// TODO: figure out what to do about screen scaling with .main being deprecated +// - maxWidth assume already scaled? + extension BaseItemDto { // MARK: Item Images func imageURL( _ type: ImageType, - maxWidth: Int? = nil, - maxHeight: Int? = nil + maxWidth: CGFloat? = nil, + maxHeight: CGFloat? = nil ) -> URL? { _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: id ?? "") } - func imageURL( - _ type: ImageType, - maxWidth: CGFloat? = nil, - maxHeight: CGFloat? = nil - ) -> URL? { - _imageURL(type, maxWidth: Int(maxWidth), maxHeight: Int(maxHeight), itemID: id ?? "") - } - + // TODO: will server actually only have a single blurhash per type? + // - makes `firstBlurHash` redundant func blurHash(_ type: ImageType) -> String? { guard type != .logo else { return nil } + if let tag = imageTags?[type.rawValue], let taggedBlurHash = imageBlurHashes?[type]?[tag] { return taggedBlurHash } else if let firstBlurHash = imageBlurHashes?[type]?.values.first { @@ -42,49 +40,62 @@ extension BaseItemDto { return nil } - func imageSource(_ type: ImageType, maxWidth: Int? = nil, maxHeight: Int? = nil) -> ImageSource { - _imageSource(type, maxWidth: maxWidth, maxHeight: maxHeight) - } - func imageSource(_ type: ImageType, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> ImageSource { - _imageSource(type, maxWidth: Int(maxWidth), maxHeight: Int(maxHeight)) + _imageSource( + type, + maxWidth: maxWidth, + maxHeight: maxHeight + ) } // MARK: Series Images - func seriesImageURL(_ type: ImageType, maxWidth: Int? = nil, maxHeight: Int? = nil) -> URL? { - _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: seriesID ?? "") - } - + /// - Note: Will force the creation of an image source even if it doesn't have a tag, due + /// to episodes also retrieving series images in some areas. This may cause more 404s. func seriesImageURL(_ type: ImageType, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> URL? { - _imageURL(type, maxWidth: Int(maxWidth), maxHeight: Int(maxHeight), itemID: seriesID ?? "") - } - - func seriesImageSource(_ type: ImageType, maxWidth: Int? = nil, maxHeight: Int? = nil) -> ImageSource { - let url = _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: seriesID ?? "") - return ImageSource(url: url, blurHash: nil) + _imageURL( + type, + maxWidth: maxWidth, + maxHeight: maxHeight, + itemID: seriesID ?? "", + force: true + ) } + /// - Note: Will force the creation of an image source even if it doesn't have a tag, due + /// to episodes also retrieving series images in some areas. This may cause more 404s. func seriesImageSource(_ type: ImageType, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> ImageSource { - seriesImageSource(type, maxWidth: Int(maxWidth), maxHeight: Int(maxWidth)) + let url = _imageURL( + type, + maxWidth: maxWidth, + maxHeight: maxHeight, + itemID: seriesID ?? "", + force: true + ) + + return ImageSource( + url: url, + blurHash: nil + ) } - func seriesImageSource(_ type: ImageType, maxWidth: CGFloat) -> ImageSource { - seriesImageSource(type, maxWidth: Int(maxWidth)) - } + // MARK: private - // MARK: Fileprivate - - fileprivate func _imageURL( + private func _imageURL( _ type: ImageType, - maxWidth: Int?, - maxHeight: Int?, - itemID: String + maxWidth: CGFloat?, + maxHeight: CGFloat?, + itemID: String, + force: Bool = false ) -> URL? { let scaleWidth = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) let scaleHeight = maxHeight == nil ? nil : UIScreen.main.scale(maxHeight!) - guard let tag = getImageTag(for: type) else { return nil } + let tag = getImageTag(for: type) + + if tag == nil && !force { + return nil + } let client = Container.userSession().client let parameters = Paths.GetItemImageParameters( @@ -105,17 +116,21 @@ extension BaseItemDto { private func getImageTag(for type: ImageType) -> String? { switch type { case .backdrop: - return backdropImageTags?.first + backdropImageTags?.first case .screenshot: - return screenshotImageTags?.first + screenshotImageTags?.first default: - return imageTags?[type.rawValue] + imageTags?[type.rawValue] } } - private func _imageSource(_ type: ImageType, maxWidth: Int?, maxHeight: Int?) -> ImageSource { + private func _imageSource(_ type: ImageType, maxWidth: CGFloat?, maxHeight: CGFloat?) -> ImageSource { let url = _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: id ?? "") let blurHash = blurHash(type) - return ImageSource(url: url, blurHash: blurHash) + + return ImageSource( + url: url, + blurHash: blurHash + ) } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift index 97b34345..3695ba06 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift @@ -15,94 +15,82 @@ import UIKit extension BaseItemDto: Poster { - var title: String { - switch type { - case .episode: - return seriesName ?? displayTitle - default: - return displayTitle - } - } - var subtitle: String? { switch type { case .episode: - return seasonEpisodeLabel + seasonEpisodeLabel case .video: - return extraType?.displayTitle + extraType?.displayTitle default: - return nil + nil } } var showTitle: Bool { switch type { case .episode, .series, .movie, .boxSet, .collectionFolder: - return Defaults[.Customization.showPosterLabels] + Defaults[.Customization.showPosterLabels] default: - return true + true } } - var typeSystemImage: String? { + var systemImage: String { switch type { case .boxSet: "film.stack" + case .channel, .tvChannel, .liveTvChannel, .program: + "tv" case .episode, .movie, .series: "film" case .folder: "folder.fill" case .person: "person.fill" - case .program: - "tv" - default: nil - } - } - - func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { - switch type { - case .episode: - return seriesImageSource(.primary, maxWidth: maxWidth) - case .folder: - return ImageSource() default: - return imageSource(.primary, maxWidth: maxWidth) + "circle" } } - func landscapePosterImageSources(maxWidth: CGFloat, single: Bool = false) -> [ImageSource] { + func portraitImageSources(maxWidth: CGFloat? = nil) -> [ImageSource] { switch type { case .episode: - if single || !Defaults[.Customization.Episodes.useSeriesLandscapeBackdrop] { - return [imageSource(.primary, maxWidth: maxWidth)] - } else { - return [ + [seriesImageSource(.primary, maxWidth: maxWidth)] + case .channel, .tvChannel, .liveTvChannel, .movie, .series: + [imageSource(.primary, maxWidth: maxWidth)] + default: + [] + } + } + + func landscapeImageSources(maxWidth: CGFloat? = nil) -> [ImageSource] { + switch type { + case .episode: + if Defaults[.Customization.Episodes.useSeriesLandscapeBackdrop] { + [ seriesImageSource(.thumb, maxWidth: maxWidth), seriesImageSource(.backdrop, maxWidth: maxWidth), imageSource(.primary, maxWidth: maxWidth), ] + } else { + [imageSource(.primary, maxWidth: maxWidth)] } - case .folder: - return [imageSource(.primary, maxWidth: maxWidth)] - case .program: - return [imageSource(.primary, maxWidth: maxWidth)] - case .video: - return [imageSource(.primary, maxWidth: maxWidth)] + case .folder, .program, .video: + [imageSource(.primary, maxWidth: maxWidth)] default: - return [ + [ imageSource(.thumb, maxWidth: maxWidth), imageSource(.backdrop, maxWidth: maxWidth), ] } } - func cinematicPosterImageSources() -> [ImageSource] { + func cinematicImageSources(maxWidth: CGFloat? = nil) -> [ImageSource] { switch type { case .episode: - return [seriesImageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)] + [seriesImageSource(.backdrop, maxWidth: maxWidth)] default: - return [imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)] + [imageSource(.backdrop, maxWidth: maxWidth)] } } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index bdced5d3..51a4fb03 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift @@ -244,28 +244,6 @@ extension BaseItemDto { } } - // TODO: move as extension on `BaseItemKind` - // TODO: remove when `collectionType` becomes an enum - func includedItemTypesForCollectionType() -> [BaseItemKind]? { - - guard let collectionType else { return nil } - - var itemTypes: [BaseItemKind]? - - switch collectionType { - case "movies": - itemTypes = [.movie] - case "tvshows": - itemTypes = [.series] - case "mixed": - itemTypes = [.movie, .series] - default: - itemTypes = nil - } - - return itemTypes - } - /// Returns `originalTitle` if it is not the same as `displayTitle` var alternateTitle: String? { originalTitle != displayTitle ? originalTitle : nil diff --git a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift index 869b93a5..bd3cbcd6 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift @@ -17,19 +17,19 @@ extension BaseItemPerson: Poster { firstRole } - var showTitle: Bool { - true - } - - var typeSystemImage: String? { + var systemImage: String { "person.fill" } - func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { - let scaleWidth = UIScreen.main.scale(maxWidth) + func portraitImageSources(maxWidth: CGFloat? = nil) -> [ImageSource] { + + // TODO: figure out what to do about screen scaling with .main being deprecated + // - maxWidth assume already scaled? + let scaleWidth: Int? = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) + let client = Container.userSession().client let imageRequestParameters = Paths.GetItemImageParameters( - maxWidth: scaleWidth, + maxWidth: scaleWidth ?? Int(maxWidth), tag: primaryImageTag ) @@ -40,17 +40,11 @@ extension BaseItemPerson: Poster { ) let url = client.fullURL(with: imageRequest) + let blurHash: String? = imageBlurHashes?.primary?[primaryImageTag] - var blurHash: String? - - if let tag = primaryImageTag, let taggedBlurHash = imageBlurHashes?.primary?[tag] { - blurHash = taggedBlurHash - } - - return ImageSource(url: url, blurHash: blurHash) - } - - func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] { - [] + return [ImageSource( + url: url, + blurHash: blurHash + )] } } diff --git a/Shared/Extensions/JellyfinAPI/ChapterInfo.swift b/Shared/Extensions/JellyfinAPI/ChapterInfo.swift index 6c260906..a120292c 100644 --- a/Shared/Extensions/JellyfinAPI/ChapterInfo.swift +++ b/Shared/Extensions/JellyfinAPI/ChapterInfo.swift @@ -45,7 +45,7 @@ extension ChapterInfo { chapterInfo.displayTitle } - let typeSystemImage: String? = "film" + let systemImage: String = "film" var subtitle: String? var showTitle: Bool = true @@ -59,11 +59,7 @@ extension ChapterInfo { self.secondsRange = secondsRange } - func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { - .init() - } - - func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] { + func landscapeImageSources(maxWidth: CGFloat?) -> [ImageSource] { [imageSource] } } diff --git a/Shared/Extensions/JellyfinAPI/JellyfinClient.swift b/Shared/Extensions/JellyfinAPI/JellyfinClient.swift index f38da433..253d8907 100644 --- a/Shared/Extensions/JellyfinAPI/JellyfinClient.swift +++ b/Shared/Extensions/JellyfinAPI/JellyfinClient.swift @@ -16,8 +16,8 @@ extension JellyfinClient { guard let path = request.url?.path else { return configuration.url } guard let fullPath = fullURL(with: path) else { return nil } + guard var components = URLComponents(string: fullPath.absoluteString) else { return nil } - var components = URLComponents(string: fullPath.absoluteString)! components.queryItems = request.query?.map { URLQueryItem(name: $0.0, value: $0.1) } ?? [] return components.url ?? fullPath diff --git a/Shared/Extensions/JellyfinAPI/UserDto.swift b/Shared/Extensions/JellyfinAPI/UserDto.swift index 384d4f05..3c4fbfa3 100644 --- a/Shared/Extensions/JellyfinAPI/UserDto.swift +++ b/Shared/Extensions/JellyfinAPI/UserDto.swift @@ -26,6 +26,6 @@ extension UserDto { let profileImageURL = client.fullURL(with: request) - return ImageSource(url: profileImageURL, blurHash: nil) + return ImageSource(url: profileImageURL) } } diff --git a/Shared/Extensions/ViewExtensions/Backport.swift b/Shared/Extensions/ViewExtensions/Backport.swift index 16fe5c54..8a7873fb 100644 --- a/Shared/Extensions/ViewExtensions/Backport.swift +++ b/Shared/Extensions/ViewExtensions/Backport.swift @@ -22,7 +22,7 @@ extension Backport where Content: View { .lineLimit(limit, reservesSpace: reservesSpace) } else { ZStack(alignment: .top) { - Text(String(repeating: "\n", count: limit - 1)) + Text(String(repeating: " \n", count: limit)) content .lineLimit(limit) diff --git a/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift index ab26a46c..7bda4157 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/AttributeStyleModifier.swift @@ -8,6 +8,8 @@ import SwiftUI +// TODO: remove as a `ViewModifier` and instead a wrapper view + struct AttributeViewModifier: ViewModifier { enum Style { diff --git a/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift index b589e889..526377ea 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift @@ -34,7 +34,7 @@ struct BackgroundParallaxHeaderModifier: ViewModifier { func body(content: Content) -> some View { content - .size($contentSize) + .trackingSize($contentSize) .background(alignment: .top) { header() .offset(y: scrollViewOffset > 0 ? -scrollViewOffset * multiplier : 0) diff --git a/Shared/Extensions/ViewExtensions/Modifiers/ScenePhaseChangeModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnScenePhaseChangedModifier.swift similarity index 91% rename from Shared/Extensions/ViewExtensions/Modifiers/ScenePhaseChangeModifier.swift rename to Shared/Extensions/ViewExtensions/Modifiers/OnScenePhaseChangedModifier.swift index b283ef52..5ccc3ff7 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/ScenePhaseChangeModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/OnScenePhaseChangedModifier.swift @@ -8,7 +8,7 @@ import SwiftUI -struct ScenePhaseChangeModifier: ViewModifier { +struct OnScenePhaseChangedModifier: ViewModifier { @Environment(\.scenePhase) private var scenePhase diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift new file mode 100644 index 00000000..5fe84f02 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift @@ -0,0 +1,23 @@ +// +// 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 OnSizeChangedModifier: ViewModifier { + + @State + private var size: CGSize = .zero + + @ViewBuilder + var wrapped: (CGSize) -> Wrapped + + func body(content: Content) -> some View { + wrapped(size) + .trackingSize($size) + } +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/RatioCornerRadiusModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/RatioCornerRadiusModifier.swift deleted file mode 100644 index 4fd4b0bd..00000000 --- a/Shared/Extensions/ViewExtensions/Modifiers/RatioCornerRadiusModifier.swift +++ /dev/null @@ -1,27 +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 SwiftUI - -struct RatioCornerRadiusModifier: ViewModifier { - - @State - private var cornerRadius: CGFloat = 0 - - let corners: UIRectCorner - let ratio: CGFloat - let side: KeyPath - - func body(content: Content) -> some View { - content - .cornerRadius(cornerRadius, corners: corners) - .onSizeChanged { newSize in - cornerRadius = newSize[keyPath: side] * ratio - } - } -} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/AfterLastDisappearModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/SinceLastDisappearModifier.swift similarity index 93% rename from Shared/Extensions/ViewExtensions/Modifiers/AfterLastDisappearModifier.swift rename to Shared/Extensions/ViewExtensions/Modifiers/SinceLastDisappearModifier.swift index a2f20c2a..67a54155 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/AfterLastDisappearModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/SinceLastDisappearModifier.swift @@ -8,7 +8,7 @@ import SwiftUI -struct AfterLastDisappearModifier: ViewModifier { +struct SinceLastDisappearModifier: ViewModifier { @State private var lastDisappear: Date? = nil diff --git a/Shared/Extensions/ViewExtensions/PreferenceKeys.swift b/Shared/Extensions/ViewExtensions/PreferenceKeys.swift index 5b8b0b59..5afbcd35 100644 --- a/Shared/Extensions/ViewExtensions/PreferenceKeys.swift +++ b/Shared/Extensions/ViewExtensions/PreferenceKeys.swift @@ -9,22 +9,24 @@ import SwiftUI struct FramePreferenceKey: PreferenceKey { + static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) {} } struct GeometryPrefenceKey: PreferenceKey { - static var defaultValue: Value = Value(size: .zero, safeAreaInsets: .init(top: 0, leading: 0, bottom: 0, trailing: 0)) - static func reduce(value: inout Value, nextValue: () -> Value) {} - struct Value: Equatable { let size: CGSize let safeAreaInsets: EdgeInsets } + + static var defaultValue: Value = Value(size: .zero, safeAreaInsets: .init(top: 0, leading: 0, bottom: 0, trailing: 0)) + static func reduce(value: inout Value, nextValue: () -> Value) {} } struct LocationPreferenceKey: PreferenceKey { + static var defaultValue: CGPoint = .zero static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {} } diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 002c271b..caec9e4b 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -89,23 +89,38 @@ extension View { } } - /// Applies the aspect ratio and corner radius for the given `PosterType` + /// Applies the aspect ratio, corner radius, and border for the given `PosterType` @ViewBuilder - func posterStyle(_ type: PosterType, contentMode: ContentMode = .fill) -> some View { + func posterStyle(_ type: PosterDisplayType, contentMode: ContentMode = .fill) -> some View { switch type { - case .portrait: - aspectRatio(2 / 3, contentMode: contentMode) - #if !os(tvOS) - .cornerRadius(ratio: 0.0375, of: \.width) - #endif case .landscape: aspectRatio(1.77, contentMode: contentMode) #if !os(tvOS) + .posterBorder(ratio: 1 / 30, of: \.width) .cornerRadius(ratio: 1 / 30, of: \.width) #endif + case .portrait: + aspectRatio(2 / 3, contentMode: contentMode) + #if !os(tvOS) + .posterBorder(ratio: 0.0375, of: \.width) + .cornerRadius(ratio: 0.0375, of: \.width) + #endif } } + func posterBorder(ratio: CGFloat, of side: KeyPath) -> some View { + modifier(OnSizeChangedModifier { size in + overlay { + RoundedRectangle(cornerRadius: size[keyPath: side] * ratio) + .stroke( + .white.opacity(0.10), + lineWidth: 2 + ) + .clipped() + } + }) + } + // TODO: remove @inlinable func padding2(_ edges: Edge.Set = .all) -> some View { @@ -139,7 +154,9 @@ extension View { /// Apply a corner radius as a ratio of a view's side func cornerRadius(ratio: CGFloat, of side: KeyPath, corners: UIRectCorner = .allCorners) -> some View { - modifier(RatioCornerRadiusModifier(corners: corners, ratio: ratio, side: side)) + modifier(OnSizeChangedModifier { size in + cornerRadius(size[keyPath: side] * ratio, corners: corners) + }) } func onFrameChanged(_ onChange: @escaping (CGRect) -> Void) -> some View { @@ -152,8 +169,7 @@ extension View { .onPreferenceChange(FramePreferenceKey.self, perform: onChange) } - // TODO: probably rename since this doesn't set the frame but tracks it - func frame(_ binding: Binding) -> some View { + func trackingFrame(_ binding: Binding) -> some View { onFrameChanged { newFrame in binding.wrappedValue = newFrame } @@ -174,8 +190,7 @@ extension View { .onPreferenceChange(LocationPreferenceKey.self, perform: onChange) } - // TODO: probably rename since this doesn't set the location but tracks it - func location(_ binding: Binding) -> some View { + func trackingLocation(_ binding: Binding) -> some View { onLocationChanged { newLocation in binding.wrappedValue = newLocation } @@ -204,8 +219,7 @@ extension View { } } - // TODO: probably rename since this doesn't set the size but tracks it - func size(_ binding: Binding) -> some View { + func trackingSize(_ binding: Binding) -> some View { onSizeChanged { newSize in binding.wrappedValue = newSize } @@ -272,11 +286,11 @@ extension View { } func onScenePhase(_ phase: ScenePhase, _ action: @escaping () -> Void) -> some View { - modifier(ScenePhaseChangeModifier(phase: phase, action: action)) + modifier(OnScenePhaseChangedModifier(phase: phase, action: action)) } func edgePadding(_ edges: Edge.Set = .all) -> some View { - padding(edges, EdgeInsets.defaultEdgePadding) + padding(edges, EdgeInsets.edgePadding) } var backport: Backport { @@ -293,10 +307,10 @@ extension View { modifier(OnFirstAppearModifier(action: action)) } - /// Perform an action as a view appears given the time interval - /// from when this view last disappeared. - func afterLastDisappear(perform action: @escaping (TimeInterval) -> Void) -> some View { - modifier(AfterLastDisappearModifier(action: action)) + /// Perform an action as the view appears given the time interval + /// since the view last disappeared. + func sinceLastDisappear(perform action: @escaping (TimeInterval) -> Void) -> some View { + modifier(SinceLastDisappearModifier(action: action)) } func topBarTrailing(@ViewBuilder content: @escaping () -> some View) -> some View { diff --git a/Shared/Objects/CaseIterablePicker.swift b/Shared/Objects/CaseIterablePicker.swift index b6987b26..c7f71548 100644 --- a/Shared/Objects/CaseIterablePicker.swift +++ b/Shared/Objects/CaseIterablePicker.swift @@ -37,6 +37,9 @@ struct CaseIterablePicker: View @Binding private var selection: Element? + @ViewBuilder + private var label: (Element) -> any View + private let title: String private let hasNone: Bool private var noneStyle: NoneStyle @@ -50,18 +53,22 @@ struct CaseIterablePicker: View } ForEach(Element.allCases.asArray, id: \.hashValue) { - Text($0.displayTitle) + label($0) + .eraseToAnyView() .tag($0 as Element?) } } } } +// MARK: Text + extension CaseIterablePicker { init(title: String, selection: Binding) { self.init( selection: selection, + label: { Text($0.displayTitle) }, title: title, hasNone: true, noneStyle: .text @@ -69,8 +76,6 @@ extension CaseIterablePicker { } init(title: String, selection: Binding) { - self.title = title - let binding = Binding { selection.wrappedValue } set: { newValue, _ in @@ -78,13 +83,48 @@ extension CaseIterablePicker { selection.wrappedValue = newValue! } - self._selection = binding - - self.hasNone = false - self.noneStyle = .text + self.init( + selection: binding, + label: { Text($0.displayTitle) }, + title: title, + hasNone: false, + noneStyle: .text + ) } func noneStyle(_ newStyle: NoneStyle) -> Self { copy(modifying: \.noneStyle, with: newStyle) } } + +// MARK: Label + +extension CaseIterablePicker where Element: SystemImageable { + + init(title: String, selection: Binding) { + self.init( + selection: selection, + label: { Label($0.displayTitle, systemImage: $0.systemImage) }, + title: title, + hasNone: true, + noneStyle: .text + ) + } + + init(title: String, selection: Binding) { + let binding = Binding { + selection.wrappedValue + } set: { newValue, _ in + precondition(newValue != nil, "Should not have nil new value with non-optional binding") + selection.wrappedValue = newValue! + } + + self.init( + selection: binding, + label: { Label($0.displayTitle, systemImage: $0.systemImage) }, + title: title, + hasNone: false, + noneStyle: .text + ) + } +} diff --git a/Shared/Objects/ChannelProgram.swift b/Shared/Objects/ChannelProgram.swift index bd2704f4..f556495b 100644 --- a/Shared/Objects/ChannelProgram.swift +++ b/Shared/Objects/ChannelProgram.swift @@ -46,15 +46,7 @@ extension ChannelProgram: Poster { channel.displayTitle } - var subtitle: String? { - nil - } - - var typeSystemImage: String? { - "tv" - } - - func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { - channel.imageSource(.primary, maxWidth: maxWidth) + var systemImage: String { + channel.systemImage } } diff --git a/Shared/Objects/Displayable.swift b/Shared/Objects/Displayable.swift index 2697c7a2..935efd34 100644 --- a/Shared/Objects/Displayable.swift +++ b/Shared/Objects/Displayable.swift @@ -8,6 +8,8 @@ import Foundation +/// A type that is displayed with a title protocol Displayable { + var displayTitle: String { get } } diff --git a/Shared/Objects/ImageSource.swift b/Shared/Objects/ImageSource.swift index 1f5f24ee..b08c43c2 100644 --- a/Shared/Objects/ImageSource.swift +++ b/Shared/Objects/ImageSource.swift @@ -8,6 +8,10 @@ import Foundation +/// Represents an image source along with a blur hash and a system image +/// to act as placeholders. +/// +/// If `blurHash` is `nil`, the given system image is used instead. struct ImageSource: Hashable { let url: URL? diff --git a/Shared/Objects/LibraryViewType.swift b/Shared/Objects/LibraryDisplayType.swift similarity index 63% rename from Shared/Objects/LibraryViewType.swift rename to Shared/Objects/LibraryDisplayType.swift index 6a189d62..15a48dcb 100644 --- a/Shared/Objects/LibraryViewType.swift +++ b/Shared/Objects/LibraryDisplayType.swift @@ -10,7 +10,7 @@ import Defaults import Foundation import UIKit -enum LibraryViewType: String, CaseIterable, Displayable, Defaults.Serializable { +enum LibraryDisplayType: String, CaseIterable, Displayable, Defaults.Serializable, SystemImageable { case grid case list @@ -24,4 +24,13 @@ enum LibraryViewType: String, CaseIterable, Displayable, Defaults.Serializable { "List" } } + + var systemImage: String { + switch self { + case .grid: + "square.grid.2x2.fill" + case .list: + "square.fill.text.grid.1x2" + } + } } diff --git a/Shared/Objects/Poster.swift b/Shared/Objects/Poster.swift index 3696a90d..4d21bda1 100644 --- a/Shared/Objects/Poster.swift +++ b/Shared/Objects/Poster.swift @@ -8,35 +8,53 @@ import Foundation -// TODO: remove `showTitle` and `subtitle` since the PosterButton can define custom supplementary views? -// TODO: instead of the below image functions, have functions that match `ImageType` -// - allows caller to choose images -protocol Poster: Displayable, Hashable, Identifiable { +/// A type that is displayed as a poster +protocol Poster: Displayable, Hashable, Identifiable, SystemImageable { + /// Optional subtitle when used as a poster var subtitle: String? { get } - var showTitle: Bool { get } - var typeSystemImage: String? { get } - func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource - func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] - func cinematicPosterImageSources() -> [ImageSource] + /// Show the title + var showTitle: Bool { get } + + func portraitImageSources( + maxWidth: CGFloat? + ) -> [ImageSource] + + func landscapeImageSources( + maxWidth: CGFloat? + ) -> [ImageSource] + + func cinematicImageSources( + maxWidth: CGFloat? + ) -> [ImageSource] } extension Poster { + var subtitle: String? { + nil + } + var showTitle: Bool { true } - func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { - .init() - } - - func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] { + func portraitImageSources( + maxWidth: CGFloat? = nil + ) -> [ImageSource] { [] } - func cinematicPosterImageSources() -> [ImageSource] { + func landscapeImageSources( + maxWidth: CGFloat? = nil + ) -> [ImageSource] { + [] + } + + func cinematicImageSources( + maxWidth: CGFloat? + ) -> [ImageSource] { [] } } diff --git a/Shared/Objects/PosterDisplayType.swift b/Shared/Objects/PosterDisplayType.swift new file mode 100644 index 00000000..6cbea47c --- /dev/null +++ b/Shared/Objects/PosterDisplayType.swift @@ -0,0 +1,26 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +enum PosterDisplayType: String, CaseIterable, Displayable, Defaults.Serializable { + + case landscape + case portrait + + // TODO: localize + var displayTitle: String { + switch self { + case .landscape: + "Landscape" + case .portrait: + "Portrait" + } + } +} diff --git a/Shared/Objects/PosterType.swift b/Shared/Objects/PosterType.swift deleted file mode 100644 index 9670a564..00000000 --- a/Shared/Objects/PosterType.swift +++ /dev/null @@ -1,34 +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 Defaults -import SwiftUI - -// TODO: Refactor to `ItemDisplayType` -// - this is to move away from video specific to generalizing all media types. However, -// media is still able to use grammar for their own contexts. -// - move landscape/portrait to wide/narrow -// - add `square`/something similar -// TODO: after no longer experimental, nest under `Poster`? -// tracker: https://github.com/apple/swift-evolution/blob/main/proposals/0404-nested-protocols.md - -enum PosterType: String, CaseIterable, Displayable, Defaults.Serializable { - - case landscape - case portrait - - // TODO: localize - var displayTitle: String { - switch self { - case .landscape: - "Landscape" - case .portrait: - "Portrait" - } - } -} diff --git a/Shared/Objects/SystemImageable.swift b/Shared/Objects/SystemImageable.swift new file mode 100644 index 00000000..9dbef46f --- /dev/null +++ b/Shared/Objects/SystemImageable.swift @@ -0,0 +1,14 @@ +// +// 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 + +protocol SystemImageable { + + var systemImage: String { get } +} diff --git a/Shared/Services/SwiftfinDefaults.swift b/Shared/Services/SwiftfinDefaults.swift index b6bb5561..bf72acdc 100644 --- a/Shared/Services/SwiftfinDefaults.swift +++ b/Shared/Services/SwiftfinDefaults.swift @@ -34,15 +34,15 @@ extension Defaults.Keys { static let itemViewType = Key("itemViewType", default: .compactLogo, suite: .generalSuite) static let showPosterLabels = Key("showPosterLabels", default: true, suite: .generalSuite) - static let nextUpPosterType = Key("nextUpPosterType", default: .portrait, suite: .generalSuite) - static let recentlyAddedPosterType = Key("recentlyAddedPosterType", default: .portrait, suite: .generalSuite) - static let latestInLibraryPosterType = Key("latestInLibraryPosterType", default: .portrait, suite: .generalSuite) + static let nextUpPosterType = Key("nextUpPosterType", default: .portrait, suite: .generalSuite) + static let recentlyAddedPosterType = Key("recentlyAddedPosterType", default: .portrait, suite: .generalSuite) + static let latestInLibraryPosterType = Key("latestInLibraryPosterType", default: .portrait, suite: .generalSuite) static let shouldShowMissingSeasons = Key("shouldShowMissingSeasons", default: true, suite: .generalSuite) static let shouldShowMissingEpisodes = Key("shouldShowMissingEpisodes", default: true, suite: .generalSuite) - static let similarPosterType = Key("similarPosterType", default: .portrait, suite: .generalSuite) + static let similarPosterType = Key("similarPosterType", default: .portrait, suite: .generalSuite) // TODO: have search poster type by types of items if applicable - static let searchPosterType = Key("searchPosterType", default: .portrait, suite: .generalSuite) + static let searchPosterType = Key("searchPosterType", default: .portrait, suite: .generalSuite) enum CinematicItemViewType { @@ -74,12 +74,12 @@ extension Defaults.Keys { default: ItemFilterType.allCases, suite: .generalSuite ) - static let viewType = Key( + static let viewType = Key( "libraryViewType", default: .grid, suite: .generalSuite ) - static let posterType = Key( + static let posterType = Key( "libraryPosterType", default: .portrait, suite: .generalSuite diff --git a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift index ed52c742..b7538d31 100644 --- a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift @@ -16,10 +16,6 @@ import UIKit /// Magic number for page sizes private let DefaultPageSize = 50 -// TODO: frankly this is just generic because we also view `BaseItemPerson` elements -// and I don't want additional views for it. Is there a way we can transform a -// `BaseItemPerson` into a `BaseItemDto` and just use the concrete type? - // TODO: fix how `hasNextPage` is determined // - some subclasses might not have "paging" and only have one call. This can be solved with // a check if elements were actually appended to the set but that requires a redundant get @@ -301,6 +297,8 @@ class PagingLibraryViewModel: ViewModel, Eventful, Stateful { [] } + /// Gets a random item from `elements`. Override if item should + /// come from another source instead. func getRandomItem() async throws -> Element? { elements.randomElement() } diff --git a/Shared/ViewModels/ProgramsViewModel.swift b/Shared/ViewModels/ProgramsViewModel.swift index 37d1e85f..67c90d95 100644 --- a/Shared/ViewModels/ProgramsViewModel.swift +++ b/Shared/ViewModels/ProgramsViewModel.swift @@ -10,10 +10,6 @@ import Combine import Foundation import JellyfinAPI -// TODO: is current program-channel requesting best way to do it? - -// Note: section item limit is low so that total channel amount is not too much - final class ProgramsViewModel: ViewModel, Stateful { enum ProgramSection: CaseIterable { @@ -42,34 +38,34 @@ final class ProgramsViewModel: ViewModel, Stateful { } @Published - private(set) var kids: [ChannelProgram] = [] + private(set) var kids: [BaseItemDto] = [] @Published - private(set) var movies: [ChannelProgram] = [] + private(set) var movies: [BaseItemDto] = [] @Published - private(set) var news: [ChannelProgram] = [] + private(set) var news: [BaseItemDto] = [] @Published - private(set) var recommended: [ChannelProgram] = [] + private(set) var recommended: [BaseItemDto] = [] @Published - private(set) var series: [ChannelProgram] = [] + private(set) var series: [BaseItemDto] = [] @Published - private(set) var sports: [ChannelProgram] = [] + private(set) var sports: [BaseItemDto] = [] @Published final var lastAction: Action? = nil @Published final var state: State = .initial - private var programChannels: [BaseItemDto] = [] - private var currentRefreshTask: AnyCancellable? var hasNoResults: Bool { - kids.isEmpty && - movies.isEmpty && - news.isEmpty && - recommended.isEmpty && - series.isEmpty && - sports.isEmpty + [ + kids, + movies, + news, + recommended, + series, + sports, + ].allSatisfy(\.isEmpty) } func respond(to action: Action) -> State { @@ -111,10 +107,10 @@ final class ProgramsViewModel: ViewModel, Stateful { } } - private func getItemSections() async throws -> [ProgramSection: [ChannelProgram]] { + private func getItemSections() async throws -> [ProgramSection: [BaseItemDto]] { try await withThrowingTaskGroup( of: (ProgramSection, [BaseItemDto]).self, - returning: [ProgramSection: [ChannelProgram]].self + returning: [ProgramSection: [BaseItemDto]].self ) { group in // sections @@ -137,18 +133,7 @@ final class ProgramsViewModel: ViewModel, Stateful { programs[items.0] = items.1 } - // get channels for all programs at once to - // avoid going back and forth too much - let channels = try await Set(self.getChannels(for: programs.values.flatMap { $0 })) - - let result: [ProgramSection: [ChannelProgram]] = programs.mapValues { programs in - programs.compactMap { program in - guard let channel = channels.first(where: { channel in channel.id == program.channelID }) else { return nil } - return ChannelProgram(channel: channel, programs: [program]) - } - } - - return result + return programs } } @@ -158,7 +143,7 @@ final class ProgramsViewModel: ViewModel, Stateful { parameters.fields = .MinimumFields .appending(.channelInfo) parameters.isAiring = true - parameters.limit = 10 + parameters.limit = 20 parameters.userID = userSession.user.id let request = Paths.getRecommendedPrograms(parameters: parameters) @@ -173,7 +158,7 @@ final class ProgramsViewModel: ViewModel, Stateful { parameters.fields = .MinimumFields .appending(.channelInfo) parameters.hasAired = false - parameters.limit = 10 + parameters.limit = 20 parameters.userID = userSession.user.id parameters.isKids = section == .kids @@ -187,19 +172,4 @@ final class ProgramsViewModel: ViewModel, Stateful { return response.value.items ?? [] } - - private func getChannels(for programs: [BaseItemDto]) async throws -> [BaseItemDto] { - - var parameters = Paths.GetItemsByUserIDParameters() - parameters.fields = .MinimumFields - parameters.ids = programs.compactMap(\.channelID) - - let request = Paths.getItemsByUserID( - userID: userSession.user.id, - parameters: parameters - ) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } } diff --git a/Shared/ViewModels/QuickConnectViewModel.swift b/Shared/ViewModels/QuickConnectViewModel.swift index 8147f8e1..f7a833ca 100644 --- a/Shared/ViewModels/QuickConnectViewModel.swift +++ b/Shared/ViewModels/QuickConnectViewModel.swift @@ -27,7 +27,7 @@ final class QuickConnectViewModel: ViewModel, Stateful { // MARK: State // The typical quick connect lifecycle is as follows: - enum State: Equatable { + enum State: Hashable { // 0. User has not interacted with quick connect case initial // 1. User clicks quick connect @@ -53,6 +53,7 @@ final class QuickConnectViewModel: ViewModel, Stateful { @Published var state: State = .initial + var lastAction: Action? = nil let client: JellyfinClient diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index 4240aa99..f8a558c2 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -14,9 +14,10 @@ import JellyfinAPI import Pulse final class UserSignInViewModel: ViewModel, Stateful { + // MARK: Action - enum Action { + enum Action: Equatable { case signInWithUserPass(username: String, password: String) case signInWithQuickConnect(authSecret: String) case cancelSignIn @@ -24,7 +25,7 @@ final class UserSignInViewModel: ViewModel, Stateful { // MARK: State - enum State: Equatable { + enum State: Hashable { case initial case signingIn case signedIn @@ -38,6 +39,8 @@ final class UserSignInViewModel: ViewModel, Stateful { @Published var state: State = .initial + var lastAction: Action? = nil + @Published private(set) var publicUsers: [UserDto] = [] @Published diff --git a/Swiftfin tvOS/Components/CinematicBackgroundView.swift b/Swiftfin tvOS/Components/CinematicBackgroundView.swift index c900b9df..0927a6c1 100644 --- a/Swiftfin tvOS/Components/CinematicBackgroundView.swift +++ b/Swiftfin tvOS/Components/CinematicBackgroundView.swift @@ -10,8 +10,6 @@ import Combine import JellyfinAPI import SwiftUI -// TODO: better name - struct CinematicBackgroundView: View { @ObservedObject @@ -26,8 +24,8 @@ struct CinematicBackgroundView: View { RotateContentView(proxy: proxy) .onChange(of: viewModel.currentItem) { newItem in proxy.update { - ImageView(newItem?.cinematicPosterImageSources() ?? []) - .placeholder { + ImageView(newItem?.cinematicImageSources(maxWidth: nil) ?? []) + .placeholder { _ in Color.clear } .failure { @@ -49,6 +47,7 @@ struct CinematicBackgroundView: View { init() { currentItemSubject .debounce(for: 0.5, scheduler: DispatchQueue.main) + .removeDuplicates() .sink { newItem in self.currentItem = newItem } @@ -56,7 +55,6 @@ struct CinematicBackgroundView: View { } func select(item: Item) { - guard currentItem != item else { return } currentItemSubject.send(item) } } diff --git a/Swiftfin tvOS/Components/CinematicItemSelector.swift b/Swiftfin tvOS/Components/CinematicItemSelector.swift index 7721f456..6c519e39 100644 --- a/Swiftfin tvOS/Components/CinematicItemSelector.swift +++ b/Swiftfin tvOS/Components/CinematicItemSelector.swift @@ -10,6 +10,7 @@ import Combine import JellyfinAPI import SwiftUI +// TODO: make new protocol for cinematic view image provider // TODO: better name struct CinematicItemSelector: View { @@ -56,7 +57,10 @@ struct CinematicItemSelector: View { } .background(alignment: .top) { ZStack { - CinematicBackgroundView(viewModel: viewModel, initialItem: items.first) + CinematicBackgroundView( + viewModel: viewModel, + initialItem: items.first + ) LinearGradient( stops: [ diff --git a/Swiftfin tvOS/Components/NonePosterButton.swift b/Swiftfin tvOS/Components/NonePosterButton.swift index e0a4604d..33629f02 100644 --- a/Swiftfin tvOS/Components/NonePosterButton.swift +++ b/Swiftfin tvOS/Components/NonePosterButton.swift @@ -10,7 +10,7 @@ import SwiftUI struct NonePosterButton: View { - let type: PosterType + let type: PosterDisplayType var body: some View { Button { diff --git a/Swiftfin tvOS/Components/PagingLibraryView.swift b/Swiftfin tvOS/Components/PagingLibraryView.swift index 9251de99..0cddd13a 100644 --- a/Swiftfin tvOS/Components/PagingLibraryView.swift +++ b/Swiftfin tvOS/Components/PagingLibraryView.swift @@ -88,8 +88,8 @@ struct PagingLibraryView: View { // MARK: layout private static func makeLayout( - posterType: PosterType, - viewType: LibraryViewType + posterType: PosterDisplayType, + viewType: LibraryDisplayType ) -> CollectionVGridLayout { switch (posterType, viewType) { case (.landscape, .grid): diff --git a/Swiftfin tvOS/Components/PosterButton.swift b/Swiftfin tvOS/Components/PosterButton.swift index 1ec4795c..1fb1ec5c 100644 --- a/Swiftfin tvOS/Components/PosterButton.swift +++ b/Swiftfin tvOS/Components/PosterButton.swift @@ -18,13 +18,12 @@ struct PosterButton: View { private var isFocused: Bool private var item: Item - private var type: PosterType + private var type: PosterDisplayType private var horizontalAlignment: HorizontalAlignment private var content: () -> any View private var imageOverlay: () -> any View private var contextMenu: () -> any View private var onSelect: () -> Void - private var singleImage: Bool // Setting the .focused() modifier causes significant performance issues. // Only set if desiring focus changes @@ -33,9 +32,9 @@ struct PosterButton: View { private func imageView(from item: Item) -> ImageView { switch type { case .portrait: - ImageView(item.portraitPosterImageSource(maxWidth: 500)) + ImageView(item.portraitImageSources(maxWidth: 500)) case .landscape: - ImageView(item.landscapePosterImageSources(maxWidth: 500, single: singleImage)) + ImageView(item.landscapeImageSources(maxWidth: 500)) } } @@ -49,7 +48,14 @@ struct PosterButton: View { imageView(from: item) .failure { - SystemImageContentView(systemName: item.typeSystemImage) + if item.showTitle { + SystemImageContentView(systemName: item.systemImage) + } else { + SystemImageContentView( + title: item.displayTitle, + systemName: item.systemImage + ) + } } imageOverlay() @@ -80,7 +86,7 @@ struct PosterButton: View { extension PosterButton { - init(item: Item, type: PosterType, singleImage: Bool = false) { + init(item: Item, type: PosterDisplayType) { self.init( item: item, type: type, @@ -89,7 +95,6 @@ extension PosterButton { imageOverlay: { DefaultOverlay(item: item) }, contextMenu: { EmptyView() }, onSelect: {}, - singleImage: singleImage, onFocusChanged: nil ) } @@ -122,7 +127,8 @@ extension PosterButton { } } -// TODO: Shared default content? +// TODO: Shared default content with iOS? +// - check if content is generally same extension PosterButton { @@ -169,6 +175,58 @@ extension PosterButton { } } + // TODO: clean up + + // Content specific for BaseItemDto episode items + struct EpisodeContentSubtitleContent: View { + + let item: Item + + var body: some View { + if let item = item as? BaseItemDto { + // Unsure why this needs 0 spacing + // compared to other default content + VStack(alignment: .leading, spacing: 0) { + if item.showTitle, let seriesName = item.seriesName { + Text(seriesName) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .backport + .lineLimit(1, reservesSpace: true) + } + + Subtitle(item: item) + } + } + } + + struct Subtitle: View { + + let item: BaseItemDto + + var body: some View { + SeparatorHStack { + Text(item.seasonEpisodeLabel ?? .emptyDash) + + if item.showTitle { + Text(item.displayTitle) + + } else if let seriesName = item.seriesName { + Text(seriesName) + } + } + .separator { + Circle() + .frame(width: 2, height: 2) + .padding(.horizontal, 3) + } + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + // TODO: Find better way for these indicators, see EpisodeCard struct DefaultOverlay: View { diff --git a/Swiftfin tvOS/Components/PosterHStack.swift b/Swiftfin tvOS/Components/PosterHStack.swift index c490d3f6..b5c7e500 100644 --- a/Swiftfin tvOS/Components/PosterHStack.swift +++ b/Swiftfin tvOS/Components/PosterHStack.swift @@ -15,7 +15,7 @@ import SwiftUI struct PosterHStack: View { private var title: String? - private var type: PosterType + private var type: PosterDisplayType private var items: Binding> private var content: (Item) -> any View private var imageOverlay: (Item) -> any View @@ -58,8 +58,8 @@ struct PosterHStack: View { } .clipsToBounds(false) .dataPrefix(20) - .insets(horizontal: EdgeInsets.defaultEdgePadding, vertical: 20) - .itemSpacing(EdgeInsets.defaultEdgePadding - 20) + .insets(horizontal: EdgeInsets.edgePadding, vertical: 20) + .itemSpacing(EdgeInsets.edgePadding - 20) .scrollBehavior(.continuousLeadingEdge) } .focusSection() @@ -85,7 +85,7 @@ extension PosterHStack { init( title: String? = nil, - type: PosterType, + type: PosterDisplayType, items: Binding> ) { self.init( @@ -103,7 +103,7 @@ extension PosterHStack { init>( title: String? = nil, - type: PosterType, + type: PosterDisplayType, items: S ) { self.init( diff --git a/Swiftfin tvOS/Components/SeeAllPosterButton.swift b/Swiftfin tvOS/Components/SeeAllPosterButton.swift index 50dc8b58..947de6e8 100644 --- a/Swiftfin tvOS/Components/SeeAllPosterButton.swift +++ b/Swiftfin tvOS/Components/SeeAllPosterButton.swift @@ -10,7 +10,7 @@ import SwiftUI struct SeeAllPosterButton: View { - private let type: PosterType + private let type: PosterDisplayType private var onSelect: () -> Void var body: some View { @@ -37,7 +37,7 @@ struct SeeAllPosterButton: View { extension SeeAllPosterButton { - init(type: PosterType) { + init(type: PosterDisplayType) { self.init( type: type, onSelect: {} diff --git a/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift b/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift index d54213b3..9c4713a8 100644 --- a/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift +++ b/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift @@ -59,7 +59,7 @@ struct ChannelLibraryView: View { viewModel.send(.refresh) } } - .afterLastDisappear { interval in + .sinceLastDisappear { interval in // refresh after 3 hours if interval >= 10800 { viewModel.send(.refresh) diff --git a/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift b/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift index 6f22dfae..735cbacb 100644 --- a/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift +++ b/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift @@ -30,16 +30,16 @@ extension ChannelLibraryView { ZStack { Color.clear - ImageView(channel.portraitPosterImageSource(maxWidth: 110)) + ImageView(channel.portraitImageSources(maxWidth: 110)) .image { $0.aspectRatio(contentMode: .fit) } .failure { - SystemImageContentView(systemName: channel.typeSystemImage) + SystemImageContentView(systemName: channel.systemImage) .background(color: .clear) .imageFrameRatio(width: 1.5, height: 1.5) } - .placeholder { + .placeholder { _ in EmptyView() } } @@ -54,7 +54,7 @@ extension ChannelLibraryView { @ViewBuilder private func programLabel(for program: BaseItemDto) -> some View { - HStack(alignment: .top, spacing: EdgeInsets.defaultEdgePadding / 2) { + HStack(alignment: .top, spacing: EdgeInsets.edgePadding / 2) { AlternateLayoutView(alignment: .leading) { Text("00:00 AM") .monospacedDigit() @@ -104,7 +104,7 @@ extension ChannelLibraryView { Button { onSelect() } label: { - HStack(alignment: .center, spacing: EdgeInsets.defaultEdgePadding / 2) { + HStack(alignment: .center, spacing: EdgeInsets.edgePadding / 2) { channelLogo .frame(width: 110) @@ -127,7 +127,7 @@ extension ChannelLibraryView { .frame(maxWidth: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, EdgeInsets.defaultEdgePadding / 2) + .padding(.horizontal, EdgeInsets.edgePadding / 2) } .buttonStyle(.card) .frame(height: 200) diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift index ad0ad3a6..f4bc98f4 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift @@ -39,7 +39,7 @@ extension HomeView { CinematicItemSelector(items: viewModel.elements.elements) .topContent { item in ImageView(itemSelectorImageSource(for: item)) - .placeholder { + .placeholder { _ in EmptyView() } .failure { diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift index 0ebc817d..5609c8b1 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift @@ -39,7 +39,7 @@ extension HomeView { CinematicItemSelector(items: viewModel.resumeItems.elements) .topContent { item in ImageView(itemSelectorImageSource(for: item)) - .placeholder { + .placeholder { _ in EmptyView() } .failure { @@ -52,12 +52,11 @@ extension HomeView { .frame(height: 200, alignment: .bottomLeading) } .content { item in - if let subtitle = item.subtitle { - Text(subtitle) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(2) + // TODO: clean up + if item.type == .episode { + PosterButton.EpisodeContentSubtitleContent.Subtitle(item: item) + } else { + Text(" ") } } .itemImageOverlay { item in diff --git a/Swiftfin tvOS/Views/HomeView/HomeView.swift b/Swiftfin tvOS/Views/HomeView/HomeView.swift index 9ba67ff2..5dc48044 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeView.swift @@ -61,7 +61,7 @@ struct HomeView: View { viewModel.send(.refresh) } .ignoresSafeArea() - .afterLastDisappear { interval in + .sinceLastDisappear { interval in if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { viewModel.send(.backgroundRefresh) viewModel.notificationsReceived.remove(.itemMetadataDidChange) diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift index f8d78749..62b10d70 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift @@ -23,8 +23,7 @@ extension SeriesEpisodeSelector { var body: some View { PosterButton( item: episode, - type: .landscape, - singleImage: true + type: .landscape ) .content { let content: String = if episode.isUnaired { diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift index efd02955..549529f0 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift @@ -43,8 +43,8 @@ extension SeriesEpisodeSelector { .focused($focusedEpisodeID, equals: episode.id) } .scrollBehavior(.continuousLeadingEdge) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) .proxy(proxy) .onFirstAppear { guard !didScrollToPlayButtonItem else { return } @@ -106,8 +106,8 @@ extension SeriesEpisodeSelector { } } .allowScrolling(false) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) } } @@ -121,8 +121,8 @@ extension SeriesEpisodeSelector { SeriesEpisodeSelector.LoadingCard() } .allowScrolling(false) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) } } } diff --git a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift index 8b70129d..137e6612 100644 --- a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift @@ -67,7 +67,7 @@ extension ItemView { maxWidth: UIScreen.main.bounds.width * 0.4, maxHeight: 250 )) - .placeholder { + .placeholder { _ in EmptyView() } .failure { diff --git a/Swiftfin tvOS/Views/MediaView/Components/MediaItem.swift b/Swiftfin tvOS/Views/MediaView/Components/MediaItem.swift index 5e2282fe..b7ae95a3 100644 --- a/Swiftfin tvOS/Views/MediaView/Components/MediaItem.swift +++ b/Swiftfin tvOS/Views/MediaView/Components/MediaItem.swift @@ -93,7 +93,8 @@ extension MediaView { titleLabelOverlay(with: ImageView.DefaultPlaceholderView(blurHash: imageSource.blurHash)) } .failure { - ImageView.DefaultFailureView() + Color.secondarySystemFill + .opacity(0.75) .overlay { titleLabel .foregroundColor(.primary) diff --git a/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift b/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift index 300a1525..48266531 100644 --- a/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift +++ b/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift @@ -54,7 +54,7 @@ struct ProgramsView: View { @ViewBuilder private func programsSection( title: String, - keyPath: KeyPath + keyPath: KeyPath ) -> some View { PosterHStack( title: title, @@ -62,18 +62,18 @@ struct ProgramsView: View { items: programsViewModel[keyPath: keyPath] ) .content { - ProgramButtonContent(program: $0.programs[0]) + ProgramButtonContent(program: $0) } .imageOverlay { - ProgramProgressOverlay(program: $0.programs[0]) - } - .onSelect { channelProgram in - guard let mediaSource = channelProgram.channel.mediaSources?.first else { return } - router.route( - to: \.liveVideoPlayer, - LiveVideoPlayerManager(item: channelProgram.channel, mediaSource: mediaSource) - ) + ProgramProgressOverlay(program: $0) } +// .onSelect { channelProgram in +// guard let mediaSource = channelProgram.channel.mediaSources?.first else { return } +// router.route( +// to: \.liveVideoPlayer, +// LiveVideoPlayerManager(item: channelProgram.channel, mediaSource: mediaSource) +// ) +// } } var body: some View { diff --git a/Swiftfin tvOS/Views/SearchView.swift b/Swiftfin tvOS/Views/SearchView.swift index 8c8b8dde..5985c3d5 100644 --- a/Swiftfin tvOS/Views/SearchView.swift +++ b/Swiftfin tvOS/Views/SearchView.swift @@ -97,7 +97,7 @@ struct SearchView: View { private func itemsSection( title: String, keyPath: KeyPath, - posterType: PosterType + posterType: PosterDisplayType ) -> some View { PosterHStack( title: title, diff --git a/Swiftfin tvOS/Views/ServerListView.swift b/Swiftfin tvOS/Views/ServerListView.swift index 119be167..8c4d126d 100644 --- a/Swiftfin tvOS/Views/ServerListView.swift +++ b/Swiftfin tvOS/Views/ServerListView.swift @@ -26,9 +26,9 @@ struct ServerListView: View { viewModel.servers, layout: .columns( 1, - insets: EdgeInsets.DefaultEdgeInsets, - itemSpacing: EdgeInsets.defaultEdgePadding, - lineSpacing: EdgeInsets.defaultEdgePadding + insets: EdgeInsets.edgeInsets, + itemSpacing: EdgeInsets.edgePadding, + lineSpacing: EdgeInsets.edgePadding ) ) { server in ServerButton(server: server) diff --git a/Swiftfin tvOS/Views/UserListView.swift b/Swiftfin tvOS/Views/UserListView.swift index a8a537a2..4f52439f 100644 --- a/Swiftfin tvOS/Views/UserListView.swift +++ b/Swiftfin tvOS/Views/UserListView.swift @@ -32,9 +32,9 @@ struct UserListView: View { viewModel.users, layout: .minWidth( 250, - insets: EdgeInsets.DefaultEdgeInsets, - itemSpacing: EdgeInsets.defaultEdgePadding, - lineSpacing: EdgeInsets.defaultEdgePadding + insets: EdgeInsets.edgeInsets, + itemSpacing: EdgeInsets.edgePadding, + lineSpacing: EdgeInsets.edgePadding ) ) { user in UserProfileButton(user: user) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 81d14987..49baeeb0 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -178,7 +178,7 @@ E10231392BCF8A3C009D71FC /* ProgramButtonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */; }; E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231312BCF8A3C009D71FC /* ProgramProgressOverlay.swift */; }; E102313D2BCF8A3C009D71FC /* ProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231332BCF8A3C009D71FC /* ProgramsView.swift */; }; - E102313F2BCF8A3C009D71FC /* WideChannelGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */; }; + E102313F2BCF8A3C009D71FC /* DetailedChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231352BCF8A3C009D71FC /* DetailedChannelView.swift */; }; E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */; }; E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231432BCF8A51009D71FC /* ChannelProgram.swift */; }; E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10231432BCF8A51009D71FC /* ChannelProgram.swift */; }; @@ -208,6 +208,10 @@ E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1092F4B29106F9F00163F57 /* GestureAction.swift */; }; + E10B1E8A2BD76FA900A92EAF /* QuickConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1E892BD76FA900A92EAF /* QuickConnectViewModel.swift */; }; + E10B1E8B2BD76FA900A92EAF /* QuickConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1E892BD76FA900A92EAF /* QuickConnectViewModel.swift */; }; + E10B1E8E2BD7708900A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */; }; + E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; }; E10E842A29A587110064EA49 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E842929A587110064EA49 /* LoadingView.swift */; }; E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10E842B29A589860064EA49 /* NonePosterButton.swift */; }; E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA4E277BBCC4000269ED /* CGSize.swift */; }; @@ -244,7 +248,6 @@ E1153DCD2BBB633B00424D36 /* FastSVGView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1153DCB2BBB633B00424D36 /* FastSVGView.swift */; }; E1153DD02BBB634F00424D36 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DCF2BBB634F00424D36 /* SVGKit */; }; E1153DD22BBB649C00424D36 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = E1153DD12BBB649C00424D36 /* SVGKit */; }; - E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; }; E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; }; E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; }; E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */; }; @@ -304,8 +307,8 @@ E132D3C82BD200C10058A2DF /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E132D3C72BD200C10058A2DF /* CollectionVGrid */; }; E132D3CD2BD2179C0058A2DF /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E132D3CC2BD2179C0058A2DF /* CollectionVGrid */; }; E132D3CF2BD217AA0058A2DF /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E132D3CE2BD217AA0058A2DF /* CollectionVGrid */; }; - E13316FE2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13316FD2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift */; }; - E13316FF2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13316FD2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift */; }; + E13316FE2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13316FD2ADE42B6009BF865 /* OnSizeChangedModifier.swift */; }; + E13316FF2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13316FD2ADE42B6009BF865 /* OnSizeChangedModifier.swift */; }; E133328829538D8D00EE76AB /* Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328729538D8D00EE76AB /* Files.swift */; }; E133328929538D8D00EE76AB /* Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328729538D8D00EE76AB /* Files.swift */; }; E133328D2953AE4B00EE76AB /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E133328C2953AE4B00EE76AB /* CircularProgressView.swift */; }; @@ -341,7 +344,7 @@ E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* UserListViewModel.swift */; }; E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3FB2717EAE8009D4DAF /* UserListView.swift */; }; E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; - E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryViewType.swift */; }; + E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */; }; E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EF28BC9016003499D2 /* LibraryRow.swift */; }; E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */; }; E1401CA22938122C00E8B599 /* AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1401CA12938122C00E8B599 /* AppIcons.swift */; }; @@ -418,12 +421,12 @@ E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; E1575E72293E77B5001665B1 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8429229340B8300D1041A /* Utilities.swift */; }; E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */; }; - E1575E75293E77B5001665B1 /* LibraryViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryViewType.swift */; }; + E1575E75293E77B5001665B1 /* LibraryDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */; }; E1575E76293E77B5001665B1 /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756352936856700976E1F /* VideoPlayerType.swift */; }; E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */; }; E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428F28F0BDC300796AC6 /* TimeStampType.swift */; }; E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E306CC28EF6E8000537998 /* TimerProxy.swift */; }; - E1575E7D293E77B5001665B1 /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; + E1575E7D293E77B5001665B1 /* PosterDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */; }; E1575E7E293E77B5001665B1 /* ItemFilterCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535870AC2669D8DD00D05A09 /* ItemFilterCollection.swift */; }; E1575E7F293E77B5001665B1 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; }; E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */; }; @@ -461,6 +464,9 @@ E15D4F082B1B12C300442DB8 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D4F062B1B12C300442DB8 /* Backport.swift */; }; E15D4F0A2B1BD88900442DB8 /* Edge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D4F092B1BD88900442DB8 /* Edge.swift */; }; E15D4F0B2B1BD88900442DB8 /* Edge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D4F092B1BD88900442DB8 /* Edge.swift */; }; + E15D63ED2BD622A700AA665D /* CompactChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D63EC2BD622A700AA665D /* CompactChannelView.swift */; }; + E15D63EF2BD6DFC200AA665D /* SystemImageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */; }; + E15D63F02BD6DFC200AA665D /* SystemImageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */; }; E15EFA842BA167350080E926 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E15EFA832BA167350080E926 /* CollectionHStack */; }; E15EFA862BA1685F0080E926 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = E15EFA852BA1685F0080E926 /* SwiftUIIntrospect */; }; E168BD10289A4162001A6922 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD08289A4162001A6922 /* HomeView.swift */; }; @@ -621,6 +627,8 @@ E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */; }; E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */ = {isa = PBXBuildFile; productRef = E1A7B1642B9A9F7800152546 /* PreferencesView */; }; E1A7B1662B9ADAD300152546 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; + E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */; }; + E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */; }; E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButton.swift */; }; E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */; }; @@ -682,7 +690,7 @@ 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 */; }; + E1CCF12E28ABF989006CAC9E /* PosterDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */; }; E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */; }; E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */; }; E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */; }; @@ -751,8 +759,8 @@ E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */; }; E1E2F8422B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */; }; E1E2F8432B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */; }; - E1E2F8452B757E3400B75998 /* AfterLastDisappearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8442B757E3400B75998 /* AfterLastDisappearModifier.swift */; }; - E1E2F8462B757E3400B75998 /* AfterLastDisappearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8442B757E3400B75998 /* AfterLastDisappearModifier.swift */; }; + E1E2F8452B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8442B757E3400B75998 /* SinceLastDisappearModifier.swift */; }; + E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E2F8442B757E3400B75998 /* SinceLastDisappearModifier.swift */; }; E1E306CD28EF6E8000537998 /* TimerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E306CC28EF6E8000537998 /* TimerProxy.swift */; }; E1E5D5492783CDD700692DFE /* VideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* VideoPlayerSettingsView.swift */; }; E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */; }; @@ -800,11 +808,8 @@ E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A628C29B720021BC93 /* ProgressBar.swift */; }; E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A628C29B720021BC93 /* ProgressBar.swift */; }; E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; }; - E43918662AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */; }; - E43918672AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */; }; - EA2073A12BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */; }; - EA2073A22BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */; }; - EADD26FD2BAE4A6C002F05DE /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */; }; + E43918662AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */; }; + E43918672AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -979,7 +984,7 @@ E10231302BCF8A3C009D71FC /* ProgramButtonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramButtonContent.swift; sourceTree = ""; }; E10231312BCF8A3C009D71FC /* ProgramProgressOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramProgressOverlay.swift; sourceTree = ""; }; E10231332BCF8A3C009D71FC /* ProgramsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramsView.swift; sourceTree = ""; }; - E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WideChannelGridItem.swift; sourceTree = ""; }; + E10231352BCF8A3C009D71FC /* DetailedChannelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailedChannelView.swift; sourceTree = ""; }; E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryView.swift; sourceTree = ""; }; E10231432BCF8A51009D71FC /* ChannelProgram.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelProgram.swift; sourceTree = ""; }; E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryViewModel.swift; sourceTree = ""; }; @@ -999,6 +1004,8 @@ E104DC952B9E7E29008F506D /* AssertionFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertionFailureView.swift; sourceTree = ""; }; E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemViewModel.swift; sourceTree = ""; }; E1092F4B29106F9F00163F57 /* GestureAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureAction.swift; sourceTree = ""; }; + E10B1E892BD76FA900A92EAF /* QuickConnectViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickConnectViewModel.swift; sourceTree = ""; }; + E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = ""; }; E10E842929A587110064EA49 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; E10E842B29A589860064EA49 /* NonePosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonePosterButton.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSize.swift; sourceTree = ""; }; @@ -1060,7 +1067,7 @@ E12E30F229638B140022FAC9 /* ChevronButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronButton.swift; sourceTree = ""; }; E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumPickerView.swift; sourceTree = ""; }; E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeInsets.swift; sourceTree = ""; }; - E13316FD2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatioCornerRadiusModifier.swift; sourceTree = ""; }; + E13316FD2ADE42B6009BF865 /* OnSizeChangedModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnSizeChangedModifier.swift; sourceTree = ""; }; E133328729538D8D00EE76AB /* Files.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Files.swift; sourceTree = ""; }; E133328C2953AE4B00EE76AB /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; E133328E2953B71000EE76AB /* DownloadTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskView.swift; sourceTree = ""; }; @@ -1083,7 +1090,7 @@ E13DD3F82717E961009D4DAF /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; E13DD3FB2717EAE8009D4DAF /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListCoordinator.swift; sourceTree = ""; }; - E13F05EB28BC9000003499D2 /* LibraryViewType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryViewType.swift; sourceTree = ""; }; + E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryDisplayType.swift; sourceTree = ""; }; E13F05EF28BC9016003499D2 /* LibraryRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = ""; }; E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconSelectorView.swift; sourceTree = ""; }; E1401CA12938122C00E8B599 /* AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcons.swift; sourceTree = ""; }; @@ -1125,6 +1132,8 @@ E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceInfoView.swift; sourceTree = ""; }; E15D4F062B1B12C300442DB8 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; E15D4F092B1BD88900442DB8 /* Edge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Edge.swift; sourceTree = ""; }; + E15D63EC2BD622A700AA665D /* CompactChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactChannelView.swift; sourceTree = ""; }; + E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageable.swift; sourceTree = ""; }; E168BD08289A4162001A6922 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = ""; }; @@ -1237,6 +1246,7 @@ E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = ""; }; E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; + E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; E1AA331C2782541500F6439C /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; E1AA331E2782639D00F6439C /* OverlayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayType.swift; sourceTree = ""; }; E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDto.swift; sourceTree = ""; }; @@ -1285,7 +1295,7 @@ 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 = ""; }; + E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterDisplayType.swift; sourceTree = ""; }; E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectOrientationModifier.swift; sourceTree = ""; }; E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; @@ -1334,7 +1344,7 @@ E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceKeys.swift; sourceTree = ""; }; E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFinalDisappearModifier.swift; sourceTree = ""; }; E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFirstAppearModifier.swift; sourceTree = ""; }; - E1E2F8442B757E3400B75998 /* AfterLastDisappearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterLastDisappearModifier.swift; sourceTree = ""; }; + E1E2F8442B757E3400B75998 /* SinceLastDisappearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SinceLastDisappearModifier.swift; sourceTree = ""; }; E1E306CC28EF6E8000537998 /* TimerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerProxy.swift; sourceTree = ""; }; E1E5D5472783CCF900692DFE /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = ""; }; E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = ""; }; @@ -1371,9 +1381,7 @@ E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; - E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenePhaseChangeModifier.swift; sourceTree = ""; }; - EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectViewModel.swift; sourceTree = ""; }; - EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = ""; }; + E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnScenePhaseChangedModifier.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1523,6 +1531,7 @@ E1CAF65C2BA345830087D991 /* MediaViewModel */, E10231472BCF8A6D009D71FC /* ProgramsViewModel.swift */, 6334175C287DE0D0000603CE /* QuickConnectSettingsViewModel.swift */, + E10B1E892BD76FA900A92EAF /* QuickConnectViewModel.swift */, 62E632DB267D2E130063E547 /* SearchViewModel.swift */, E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */, E13DD3E027176BD3009D4DAF /* ServerListViewModel.swift */, @@ -1532,7 +1541,6 @@ BD0BA2292AD6501300306A8D /* VideoPlayerManager */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, - EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -1626,13 +1634,13 @@ E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */, E14EDECA2B8FB66F000F00A4 /* ItemFilter */, E1C925F328875037002A7A66 /* ItemViewType.swift */, + E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */, E1DE2B4E2B983F3200F6715F /* LibraryParent */, - E13F05EB28BC9000003499D2 /* LibraryViewType.swift */, E1AA331E2782639D00F6439C /* OverlayType.swift */, E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */, E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, E1937A60288F32DB00CB80AA /* Poster.swift */, - E1CCF12D28ABF989006CAC9E /* PosterType.swift */, + E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */, E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */, E1E9017A28DAAE4D001B1594 /* RoundedCorner.swift */, E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.swift */, @@ -1641,6 +1649,7 @@ E11042742B8013DF00821020 /* Stateful.swift */, E1EF4C402911B783008CC695 /* StreamType.swift */, E11BDF792B85529D0045C54A /* SupportedCaseIterable.swift */, + E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */, E1A1528428FD191A00600579 /* TextPair.swift */, E1E306CC28EF6E8000537998 /* TimerProxy.swift */, E129428F28F0BDC300796AC6 /* TimeStampType.swift */, @@ -1923,6 +1932,7 @@ 6267B3D526710B8900A7371D /* Collection.swift */, E173DA5126D04AAF00CC4EB7 /* Color.swift */, E1B490462967E2E500D3EDCE /* CoreStore.swift */, + E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */, E15756312935642A00976E1F /* Double.swift */, E15D4F092B1BD88900442DB8 /* Edge.swift */, E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */, @@ -2085,18 +2095,19 @@ path = ProgramsView; sourceTree = ""; }; - E10231362BCF8A3C009D71FC /* Component */ = { + E10231362BCF8A3C009D71FC /* Components */ = { isa = PBXGroup; children = ( - E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */, + E15D63EC2BD622A700AA665D /* CompactChannelView.swift */, + E10231352BCF8A3C009D71FC /* DetailedChannelView.swift */, ); - path = Component; + path = Components; sourceTree = ""; }; E10231382BCF8A3C009D71FC /* ChannelLibraryView */ = { isa = PBXGroup; children = ( - E10231362BCF8A3C009D71FC /* Component */, + E10231362BCF8A3C009D71FC /* Components */, E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */, ); path = ChannelLibraryView; @@ -2326,6 +2337,7 @@ E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */, E103DF932BCF31C5000229B2 /* MediaView */, E10231572BCF8AF8009D71FC /* ProgramsView */, + E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */, E1E1643928BAC2EF00323B0A /* SearchView.swift */, E193D54F2719430400900D82 /* ServerDetailView.swift */, E193D54A271941D300900D82 /* ServerListView.swift */, @@ -2333,7 +2345,6 @@ E193D546271941C500900D82 /* UserListView.swift */, E193D548271941CC00900D82 /* UserSignInView.swift */, 5310694F2684E7EE00CFFDBA /* VideoPlayer */, - EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */, ); path = Views; sourceTree = ""; @@ -2504,17 +2515,17 @@ E170D101294CE4C10017224C /* Modifiers */ = { isa = PBXGroup; children = ( - E1E2F8442B757E3400B75998 /* AfterLastDisappearModifier.swift */, E18E0202288749200022598C /* AttributeStyleModifier.swift */, E11895B22893844A0042947B /* BackgroundParallaxHeaderModifier.swift */, E19E551E2897326C003CE330 /* BottomEdgeGradientModifier.swift */, E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */, E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */, E129428428F080B500796AC6 /* OnReceiveNotificationModifier.swift */, + E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */, + E13316FD2ADE42B6009BF865 /* OnSizeChangedModifier.swift */, E1DE2B4B2B98389E00F6715F /* PaletteOverlayRenderingModifier.swift */, - E13316FD2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift */, - E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */, E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */, + E1E2F8442B757E3400B75998 /* SinceLastDisappearModifier.swift */, ); path = Modifiers; sourceTree = ""; @@ -3528,7 +3539,6 @@ E102314B2BCF8A6D009D71FC /* ProgramsViewModel.swift in Sources */, E1DD55382B6EE533007501C0 /* Task.swift in Sources */, E1575EA1293E7B1E001665B1 /* String.swift in Sources */, - EADD26FD2BAE4A6C002F05DE /* QuickConnectView.swift in Sources */, E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */, @@ -3561,6 +3571,7 @@ E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */, E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */, + E1A7F0E02BD4EC7400620DDD /* Dictionary.swift in Sources */, E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */, E111D8FA28D0400900400001 /* PagingLibraryView.swift in Sources */, E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, @@ -3606,7 +3617,7 @@ 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, E1549667296CA2EF00C4EF88 /* SwiftfinNotifications.swift in Sources */, E1575E6B293E77B5001665B1 /* Displayable.swift in Sources */, - E1E2F8462B757E3400B75998 /* AfterLastDisappearModifier.swift in Sources */, + E1E2F8462B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */, E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */, E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */, @@ -3618,22 +3629,22 @@ E12376B02A33D6AE001F5B44 /* AboutViewCard.swift in Sources */, E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */, E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */, - EA2073A22BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */, E1579EA82B97DC1500A31CA1 /* Eventful.swift in Sources */, E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */, E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */, E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */, + E10B1E8B2BD76FA900A92EAF /* QuickConnectViewModel.swift in Sources */, E1DC9842296DEBD800982F06 /* WatchedIndicator.swift in Sources */, E1575E6C293E77B5001665B1 /* SliderType.swift in Sources */, E1E2F8402B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, E10E842A29A587110064EA49 /* LoadingView.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, - E13316FF2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */, + E13316FF2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */, E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */, - E43918672AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */, + E43918672AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */, E154966F296CA2EF00C4EF88 /* LogManager.swift in Sources */, E1D37F562B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */, - E1575E75293E77B5001665B1 /* LibraryViewType.swift in Sources */, + E1575E75293E77B5001665B1 /* LibraryDisplayType.swift in Sources */, E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */, E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, E12E30F1296383810022FAC9 /* SplitFormWindowView.swift in Sources */, @@ -3687,7 +3698,7 @@ C46DD8D92A8DC2990046A504 /* LiveNativeVideoPlayer.swift in Sources */, E1575E9F293E7B1E001665B1 /* Int.swift in Sources */, E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */, - E1575E7D293E77B5001665B1 /* PosterType.swift in Sources */, + E1575E7D293E77B5001665B1 /* PosterDisplayType.swift in Sources */, E1E5D553278419D900692DFE /* ConfirmCloseOverlay.swift in Sources */, E18A17F2298C68BB00C22F62 /* MainOverlay.swift in Sources */, E1E6C44B29AED2B70064123F /* HorizontalAlignment.swift in Sources */, @@ -3717,6 +3728,7 @@ E193D53C27193F9500900D82 /* UserListCoordinator.swift in Sources */, E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */, E154967C296CBB1A00C4EF88 /* FontPickerView.swift in Sources */, + E15D63F02BD6DFC200AA665D /* SystemImageable.swift in Sources */, E193D53A27193F9000900D82 /* ServerListCoordinator.swift in Sources */, 6220D0AE26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, E1575E86293E7A00001665B1 /* AppIcons.swift in Sources */, @@ -3788,6 +3800,7 @@ E1C9261B288756BD002A7A66 /* DotHStack.swift in Sources */, E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */, E18ACA8D2A14773500BB4F35 /* (null) in Sources */, + E10B1E8E2BD7708900A92EAF /* QuickConnectView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3821,7 +3834,7 @@ 62E632EC267D410B0063E547 /* SeriesItemViewModel.swift in Sources */, 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* MediaCoordinator.swift in Sources */, - E13316FE2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift in Sources */, + E13316FE2ADE42B6009BF865 /* OnSizeChangedModifier.swift in Sources */, 62E632DC267D2E130063E547 /* SearchViewModel.swift in Sources */, E1A1528A28FD22F600600579 /* TextPairView.swift in Sources */, E1BDF2E92951490400CC0294 /* ActionButtonSelectorView.swift in Sources */, @@ -3847,7 +3860,6 @@ E1E1644128BB301900323B0A /* Array.swift in Sources */, E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */, E129429828F4785200796AC6 /* CaseIterablePicker.swift in Sources */, - EA2073A12BAE35A400D8C78F /* QuickConnectViewModel.swift in Sources */, E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */, E154965E296CA2EF00C4EF88 /* DownloadTask.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, @@ -3859,6 +3871,7 @@ E18E01FA288747580022598C /* AboutAppView.swift in Sources */, E170D103294CE8BF0017224C /* LoadingView.swift in Sources */, 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, + E15D63ED2BD622A700AA665D /* CompactChannelView.swift in Sources */, E18A8E8528D60D0000333B9A /* VideoPlayerCoordinator.swift in Sources */, E19E551F2897326C003CE330 /* BottomEdgeGradientModifier.swift in Sources */, E1A3E4CD2BB7D8C8005C59F8 /* Label-iOS.swift in Sources */, @@ -3932,6 +3945,7 @@ E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, + E10B1E8F2BD7728400A92EAF /* QuickConnectView.swift in Sources */, E1DD55372B6EE533007501C0 /* Task.swift in Sources */, E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */, 5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */, @@ -3940,7 +3954,6 @@ E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */, E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, - E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */, E1DC9819296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */, C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, @@ -3956,10 +3969,11 @@ E13F05F128BC9016003499D2 /* LibraryRow.swift in Sources */, E168BD10289A4162001A6922 /* HomeView.swift in Sources */, E1401CB129386C9200E8B599 /* UIColor.swift in Sources */, - E1E2F8452B757E3400B75998 /* AfterLastDisappearModifier.swift in Sources */, + E1E2F8452B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */, E18E01AB288746AF0022598C /* PillHStack.swift in Sources */, E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */, E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */, + E15D63EF2BD6DFC200AA665D /* SystemImageable.swift in Sources */, E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */, E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */, C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */, @@ -3992,6 +4006,7 @@ 6334175D287DE0D0000603CE /* QuickConnectSettingsViewModel.swift in Sources */, E1DA656F28E78C9900592A73 /* EpisodeSelector.swift in Sources */, E18E01E0288747230022598C /* iPadOSMovieItemContentView.swift in Sources */, + E1A7F0DF2BD4EC7400620DDD /* Dictionary.swift in Sources */, E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E10231442BCF8A51009D71FC /* ChannelProgram.swift in Sources */, E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, @@ -4000,11 +4015,11 @@ C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */, E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, - E43918662AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */, + E43918662AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */, E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */, E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */, E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */, - E102313F2BCF8A3C009D71FC /* WideChannelGridItem.swift in Sources */, + E102313F2BCF8A3C009D71FC /* DetailedChannelView.swift in Sources */, E113133228BDC72000930F75 /* FilterView.swift in Sources */, 62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */, E102313D2BCF8A3C009D71FC /* ProgramsView.swift in Sources */, @@ -4020,6 +4035,7 @@ E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */, E18E01DE288747230022598C /* iPadOSSeriesItemView.swift in Sources */, E1D37F4E2B9CEDC400343D2B /* DeviceProfile.swift in Sources */, + E10B1E8A2BD76FA900A92EAF /* QuickConnectViewModel.swift in Sources */, E1EF4C412911B783008CC695 /* StreamType.swift in Sources */, 6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */, E1A3E4CF2BB7E02B005C59F8 /* DelayedProgressView.swift in Sources */, @@ -4040,7 +4056,7 @@ 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, - E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */, + E1CCF12E28ABF989006CAC9E /* PosterDisplayType.swift in Sources */, E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */, E1D842912933F87500D1041A /* ItemFields.swift in Sources */, E1BDF2F729524ECD00CC0294 /* PlaybackSpeedActionButton.swift in Sources */, @@ -4152,7 +4168,7 @@ E1153DB42BBA80FB00424D36 /* EmptyCard.swift in Sources */, E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */, E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */, - E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */, + E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */, E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */, 5377CBF5263B596A003A4E83 /* SwiftfinApp.swift in Sources */, E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */, diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift index 543f73fb..56e90560 100644 --- a/Swiftfin/Components/PosterButton.swift +++ b/Swiftfin/Components/PosterButton.swift @@ -12,23 +12,26 @@ import SwiftUI // TODO: expose `ImageView.image` modifier for image aspect fill/fit // TODO: allow `content` to trigger `onSelect`? +// - not in button label to avoid context menu visual oddities +// TODO: get width/height for images from layout size? +// TODO: why don't shadows work with failure image views? +// - due to `Color`? struct PosterButton: View { private var item: Item - private var type: PosterType + private var type: PosterDisplayType private var content: () -> any View private var imageOverlay: () -> any View private var contextMenu: () -> any View private var onSelect: () -> Void - private var singleImage: Bool private func imageView(from item: Item) -> ImageView { switch type { - case .portrait: - ImageView(item.portraitPosterImageSource(maxWidth: 200)) case .landscape: - ImageView(item.landscapePosterImageSources(maxWidth: 500, single: singleImage)) + ImageView(item.landscapeImageSources(maxWidth: 500)) + case .portrait: + ImageView(item.portraitImageSources(maxWidth: 200)) } } @@ -42,7 +45,14 @@ struct PosterButton: View { imageView(from: item) .failure { - SystemImageContentView(systemName: item.typeSystemImage) + if item.showTitle { + SystemImageContentView(systemName: item.systemImage) + } else { + SystemImageContentView( + title: item.displayTitle, + systemName: item.systemImage + ) + } } imageOverlay() @@ -50,6 +60,7 @@ struct PosterButton: View { } .posterStyle(type) } + .buttonStyle(.plain) .contextMenu(menuItems: { contextMenu() .eraseToAnyView() @@ -66,8 +77,7 @@ extension PosterButton { init( item: Item, - type: PosterType, - singleImage: Bool = false + type: PosterDisplayType ) { self.init( item: item, @@ -75,8 +85,7 @@ extension PosterButton { content: { TitleSubtitleContentView(item: item) }, imageOverlay: { DefaultOverlay(item: item) }, contextMenu: { EmptyView() }, - onSelect: {}, - singleImage: singleImage + onSelect: {} ) } @@ -97,7 +106,8 @@ extension PosterButton { } } -// TODO: Shared default content? +// TODO: Shared default content with tvOS? +// - check if content is generally same extension PosterButton { @@ -119,7 +129,7 @@ extension PosterButton { let item: Item var body: some View { - Text(item.subtitle ?? "") + Text(item.subtitle ?? " ") .font(.caption.weight(.medium)) .foregroundColor(.secondary) } @@ -144,6 +154,49 @@ extension PosterButton { } } + // Content specific for BaseItemDto episode items + struct EpisodeContentSubtitleContent: View { + + @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) + private var useSeriesLandscapeBackdrop + + let item: Item + + var body: some View { + if let item = item as? BaseItemDto { + // Unsure why this needs 0 spacing + // compared to other default content + VStack(alignment: .leading, spacing: 0) { + if item.showTitle, let seriesName = item.seriesName { + Text(seriesName) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .backport + .lineLimit(1, reservesSpace: true) + } + + SeparatorHStack { + Text(item.seasonEpisodeLabel ?? .emptyDash) + + if item.showTitle || useSeriesLandscapeBackdrop { + Text(item.displayTitle) + } else if let seriesName = item.seriesName { + Text(seriesName) + } + } + .separator { + Circle() + .frame(width: 2, height: 2) + .padding(.horizontal, 3) + } + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + // MARK: Default Overlay struct DefaultOverlay: View { diff --git a/Swiftfin/Components/PosterHStack.swift b/Swiftfin/Components/PosterHStack.swift index 19bf89e2..5b2a76e0 100644 --- a/Swiftfin/Components/PosterHStack.swift +++ b/Swiftfin/Components/PosterHStack.swift @@ -14,9 +14,8 @@ struct PosterHStack: View { private var header: () -> any View private var title: String? - private var type: PosterType + private var type: PosterDisplayType private var items: Binding> - private var singleImage: Bool private var content: (Item) -> any View private var imageOverlay: (Item) -> any View private var contextMenu: (Item) -> any View @@ -31,8 +30,7 @@ struct PosterHStack: View { ) { item in PosterButton( item: item, - type: type, - singleImage: singleImage + type: type ) .content { content(item).eraseToAnyView() } .imageOverlay { imageOverlay(item).eraseToAnyView() } @@ -41,8 +39,8 @@ struct PosterHStack: View { } .clipsToBounds(false) .dataPrefix(20) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) .scrollBehavior(.continuousLeadingEdge) } @@ -54,8 +52,7 @@ struct PosterHStack: View { ) { item in PosterButton( item: item, - type: type, - singleImage: singleImage + type: type ) .content { content(item).eraseToAnyView() } .imageOverlay { imageOverlay(item).eraseToAnyView() } @@ -64,8 +61,8 @@ struct PosterHStack: View { } .clipsToBounds(false) .dataPrefix(20) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) .scrollBehavior(.continuousLeadingEdge) } @@ -96,16 +93,14 @@ extension PosterHStack { init( title: String? = nil, - type: PosterType, - items: Binding>, - singleImage: Bool = false + type: PosterDisplayType, + items: Binding> ) { self.init( header: { DefaultHeader(title: title) }, title: title, type: type, items: items, - singleImage: singleImage, content: { PosterButton.TitleSubtitleContentView(item: $0) }, imageOverlay: { PosterButton.DefaultOverlay(item: $0) }, contextMenu: { _ in EmptyView() }, @@ -116,15 +111,13 @@ extension PosterHStack { init>( title: String? = nil, - type: PosterType, - items: S, - singleImage: Bool = false + type: PosterDisplayType, + items: S ) { self.init( title: title, type: type, - items: .constant(OrderedSet(items)), - singleImage: singleImage + items: .constant(OrderedSet(items)) ) } diff --git a/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift b/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift index 158d6080..0267af8d 100644 --- a/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift +++ b/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift @@ -12,43 +12,100 @@ import Foundation import JellyfinAPI import SwiftUI -// TODO: wide + narrow view toggling -// - after `PosterType` has been refactored and with customizable toggle button // TODO: sorting by number/filtering -// - should be able to use normal filter view model, but how to add custom filters for data context? +// - see if can use normal filter view model? +// - how to add custom filters for data context? +// TODO: saving item display type/detailed column count +// - wait until after user refactor + +// Note: Repurposes `LibraryDisplayType` to save from creating a new type. +// If there are other places where detailed/compact contextually differ +// from the library types, then create a new type and use it here. +// - list: detailed +// - grid: compact struct ChannelLibraryView: View { @EnvironmentObject private var mainRouter: MainCoordinator.Router + @State + private var channelDisplayType: LibraryDisplayType = .list @State private var layout: CollectionVGridLayout @StateObject private var viewModel = ChannelLibraryViewModel() + // MARK: init + init() { if UIDevice.isPhone { - layout = .columns(1) + layout = Self.padlayout(channelDisplayType: .list) } else { - layout = .minWidth(250) + layout = Self.phonelayout(channelDisplayType: .list) } } + // MARK: layout + + private static func padlayout( + channelDisplayType: LibraryDisplayType + ) -> CollectionVGridLayout { + switch channelDisplayType { + case .grid: + .minWidth(150) + case .list: + .minWidth(250) + } + } + + private static func phonelayout( + channelDisplayType: LibraryDisplayType + ) -> CollectionVGridLayout { + switch channelDisplayType { + case .grid: + .columns(3) + case .list: + .columns(1) + } + } + + // MARK: item view + + private func narrowChannelView(channel: ChannelProgram) -> some View { + CompactChannelView(channel: channel.channel) + .onSelect { + guard let mediaSource = channel.channel.mediaSources?.first else { return } + mainRouter.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) + ) + } + } + + private func wideChannelView(channel: ChannelProgram) -> some View { + DetailedChannelView(channel: channel) + .onSelect { + guard let mediaSource = channel.channel.mediaSources?.first else { return } + mainRouter.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) + ) + } + } + private var contentView: some View { CollectionVGrid( $viewModel.elements, - layout: layout + layout: $layout ) { channel in - WideChannelGridItem(channel: channel) - .onSelect { - guard let mediaSource = channel.channel.mediaSources?.first else { return } - mainRouter.route( - to: \.liveVideoPlayer, - LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) - ) - } + switch channelDisplayType { + case .grid: + narrowChannelView(channel: channel) + case .list: + wideChannelView(channel: channel) + } } .onReachedBottomEdge(offset: .offset(300)) { viewModel.send(.getNextPage) @@ -79,12 +136,19 @@ struct ChannelLibraryView: View { } .navigationTitle(L10n.channels) .navigationBarTitleDisplayMode(.inline) + .onChange(of: channelDisplayType) { newValue in + if UIDevice.isPhone { + layout = Self.phonelayout(channelDisplayType: newValue) + } else { + layout = Self.padlayout(channelDisplayType: newValue) + } + } .onFirstAppear { if viewModel.state == .initial { viewModel.send(.refresh) } } - .afterLastDisappear { interval in + .sinceLastDisappear { interval in // refresh after 3 hours if interval >= 10800 { viewModel.send(.refresh) @@ -95,6 +159,23 @@ struct ChannelLibraryView: View { if viewModel.backgroundStates.contains(.gettingNextPage) { ProgressView() } + + Menu { + // We repurposed `LibraryDisplayType` but want different labels + Picker("Channel Display", selection: $channelDisplayType) { + + Label("Compact", systemImage: LibraryDisplayType.grid.systemImage) + .tag(LibraryDisplayType.grid) + + Label("Detailed", systemImage: LibraryDisplayType.list.systemImage) + .tag(LibraryDisplayType.list) + } + } label: { + Label( + channelDisplayType.displayTitle, + systemImage: channelDisplayType.systemImage + ) + } } } } diff --git a/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift b/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift new file mode 100644 index 00000000..78851ce6 --- /dev/null +++ b/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift @@ -0,0 +1,75 @@ +// +// 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 ChannelLibraryView { + + struct CompactChannelView: View { + + @Environment(\.colorScheme) + private var colorScheme + + let channel: BaseItemDto + + private var onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + VStack(alignment: .leading) { + ZStack { + Color.secondarySystemFill + .opacity(colorScheme == .dark ? 0.5 : 1) + .posterShadow() + + ImageView(channel.imageSource(.primary, maxWidth: 120)) + .image { + $0.aspectRatio(contentMode: .fit) + } + .failure { + SystemImageContentView(systemName: channel.systemImage) + .background(color: .clear) + .imageFrameRatio(width: 2, height: 2) + } + .placeholder { _ in + EmptyView() + } + .padding(5) + } + .aspectRatio(1.0, contentMode: .fill) + .cornerRadius(ratio: 0.0375, of: \.width) + .posterBorder(ratio: 0.0375, of: \.width) + + Text(channel.displayTitle) + .font(.footnote.weight(.regular)) + .foregroundColor(.primary) + .backport + .lineLimit(1, reservesSpace: true) + } + } + .buttonStyle(.plain) + } + } +} + +extension ChannelLibraryView.CompactChannelView { + + init(channel: BaseItemDto) { + self.init( + channel: channel, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin/Views/ChannelLibraryView/Component/WideChannelGridItem.swift b/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift similarity index 92% rename from Swiftfin/Views/ChannelLibraryView/Component/WideChannelGridItem.swift rename to Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift index ee8a940f..3c27a17d 100644 --- a/Swiftfin/Views/ChannelLibraryView/Component/WideChannelGridItem.swift +++ b/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift @@ -14,7 +14,7 @@ import SwiftUI extension ChannelLibraryView { - struct WideChannelGridItem: View { + struct DetailedChannelView: View { @Default(.accentColor) private var accentColor @@ -39,21 +39,22 @@ extension ChannelLibraryView { .opacity(colorScheme == .dark ? 0.5 : 1) .posterShadow() - ImageView(channel.portraitPosterImageSource(maxWidth: 80)) + ImageView(channel.channel.imageSource(.primary, maxWidth: 120)) .image { $0.aspectRatio(contentMode: .fit) } .failure { - SystemImageContentView(systemName: channel.typeSystemImage) + SystemImageContentView(systemName: channel.systemImage) .background(color: .clear) .imageFrameRatio(width: 2, height: 2) } - .placeholder { + .placeholder { _ in EmptyView() } - .padding(2) + .padding(5) } .aspectRatio(1.0, contentMode: .fill) + .posterBorder(ratio: 0.0375, of: \.width) .cornerRadius(ratio: 0.0375, of: \.width) Text(channel.channel.number ?? "") @@ -116,7 +117,7 @@ extension ChannelLibraryView { Button { onSelect() } label: { - HStack(alignment: .center, spacing: EdgeInsets.defaultEdgePadding) { + HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { channelLogo .frame(width: 80) @@ -138,7 +139,7 @@ extension ChannelLibraryView { Spacer() } .frame(maxWidth: .infinity) - .size($contentSize) + .trackingSize($contentSize) } } .buttonStyle(.plain) @@ -154,7 +155,7 @@ extension ChannelLibraryView { } } -extension ChannelLibraryView.WideChannelGridItem { +extension ChannelLibraryView.DetailedChannelView { init(channel: ChannelProgram) { self.init( diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift index ec550341..6ebca913 100644 --- a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift +++ b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift @@ -36,7 +36,7 @@ extension DownloadTaskView { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .center) { - ImageView(downloadTask.item.landscapePosterImageSources(maxWidth: 600, single: true)) + ImageView(downloadTask.item.landscapeImageSources(maxWidth: 600)) .frame(maxHeight: 300) .aspectRatio(1.77, contentMode: .fill) .cornerRadius(10) diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift index 753f9a4b..c18865e2 100644 --- a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift +++ b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift @@ -38,6 +38,13 @@ extension HomeView { columns: columnCount ) { item in PosterButton(item: item, type: .landscape) + .content { + if item.type == .episode { + PosterButton.EpisodeContentSubtitleContent(item: item) + } else { + PosterButton.TitleSubtitleContentView(item: item) + } + } .contextMenu { Button { viewModel.send(.setIsPlayed(true, item)) diff --git a/Swiftfin/Views/HomeView/Components/NextUpView.swift b/Swiftfin/Views/HomeView/Components/NextUpView.swift index cccdf69d..a5c41415 100644 --- a/Swiftfin/Views/HomeView/Components/NextUpView.swift +++ b/Swiftfin/Views/HomeView/Components/NextUpView.swift @@ -31,11 +31,12 @@ extension HomeView { type: nextUpPosterType, items: $homeViewModel.nextUpViewModel.elements ) - .trailing { - SeeAllButton() - .onSelect { - router.route(to: \.library, homeViewModel.nextUpViewModel) - } + .content { item in + if item.type == .episode { + PosterButton.EpisodeContentSubtitleContent(item: item) + } else { + PosterButton.TitleSubtitleContentView(item: item) + } } .contextMenu { item in Button { @@ -47,6 +48,12 @@ extension HomeView { .onSelect { item in router.route(to: \.item, item) } + .trailing { + SeeAllButton() + .onSelect { + router.route(to: \.library, homeViewModel.nextUpViewModel) + } + } } } } diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift index 29528121..d45e2ed2 100644 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -83,7 +83,7 @@ struct HomeView: View { .accessibilityLabel(L10n.settings) } } - .afterLastDisappear { interval in + .sinceLastDisappear { interval in if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { viewModel.send(.backgroundRefresh) viewModel.notificationsReceived.remove(.itemMetadataDidChange) diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift index f78ecc1e..94d070ea 100644 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift @@ -44,8 +44,7 @@ extension SeriesEpisodeSelector { var body: some View { PosterButton( item: episode, - type: .landscape, - singleImage: true + type: .landscape ) .content { let content: String = if episode.isUnaired { diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift index 43aa6b6e..fd6f9873 100644 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift +++ b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift @@ -36,8 +36,8 @@ extension SeriesEpisodeSelector { SeriesEpisodeSelector.EpisodeCard(episode: episode) } .scrollBehavior(.continuousLeadingEdge) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) .proxy(proxy) .onFirstAppear { guard !didScrollToPlayButtonItem else { return } @@ -77,8 +77,8 @@ extension SeriesEpisodeSelector { SeriesEpisodeSelector.EmptyCard() } .allowScrolling(false) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) } } @@ -101,8 +101,8 @@ extension SeriesEpisodeSelector { } } .allowScrolling(false) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) } } @@ -116,8 +116,8 @@ extension SeriesEpisodeSelector { SeriesEpisodeSelector.LoadingCard() } .allowScrolling(false) - .insets(horizontal: EdgeInsets.defaultEdgePadding) - .itemSpacing(EdgeInsets.defaultEdgePadding / 2) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) } } } diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift index 5d7cf1be..65c669e9 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift @@ -25,10 +25,9 @@ extension EpisodeItemView { VStack(alignment: .center) { ImageView(viewModel.item.imageSource(.primary, maxWidth: 600)) .frame(maxHeight: 300) - .aspectRatio(1.77, contentMode: .fill) - .cornerRadius(10) - .padding(.horizontal) + .posterStyle(.landscape) .posterShadow() + .padding(.horizontal) ShelfView(viewModel: viewModel) } diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift index e8a62cc3..c2958ef7 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CinematicScrollView.swift @@ -109,7 +109,7 @@ extension ItemView.CinematicScrollView { VStack(alignment: .center, spacing: 10) { if !cinematicItemViewTypeUsePrimaryImage { ImageView(viewModel.item.imageURL(.logo, maxHeight: 100)) - .placeholder { + .placeholder { _ in EmptyView() } .failure { diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift index 318333f1..b7c03afa 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactLogoScrollView.swift @@ -99,7 +99,7 @@ extension ItemView.CompactLogoScrollView { var body: some View { VStack(alignment: .center, spacing: 10) { ImageView(viewModel.item.imageURL(.logo, maxHeight: 70)) - .placeholder { + .placeholder { _ in EmptyView() } .failure { diff --git a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift index 1cc0df8a..aa64c8a8 100644 --- a/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift +++ b/Swiftfin/Views/ItemView/iOS/ScrollViews/CompactPortraitScrollView.swift @@ -143,7 +143,7 @@ extension ItemView.CompactPosterScrollView { ImageView(viewModel.item.imageSource(.primary, maxWidth: 130)) .failure { - SystemImageContentView(systemName: viewModel.item.typeSystemImage) + SystemImageContentView(systemName: viewModel.item.systemImage) } .posterStyle(.portrait, contentMode: .fit) .frame(width: 130) diff --git a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift index 06b04084..ec203e74 100644 --- a/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/ScrollViews/iPadOSCinematicScrollView.swift @@ -68,7 +68,7 @@ extension ItemView { content() .edgePadding(.vertical) } - .size($globalSize) + .trackingSize($globalSize) } } } @@ -93,7 +93,7 @@ extension ItemView.iPadOSCinematicScrollView { maxWidth: UIScreen.main.bounds.width * 0.4, maxHeight: 130 )) - .placeholder { + .placeholder { _ in EmptyView() } .failure { diff --git a/Swiftfin/Views/MediaView/Components/MediaItem.swift b/Swiftfin/Views/MediaView/Components/MediaItem.swift index 9a28adfe..c31fe887 100644 --- a/Swiftfin/Views/MediaView/Components/MediaItem.swift +++ b/Swiftfin/Views/MediaView/Components/MediaItem.swift @@ -11,6 +11,8 @@ import SwiftUI // Note: the design reason to not have a local label always on top // is to have the same failure/empty color for all views +// TODO: why don't shadows work with failure image views? +// - due to `Color`? extension MediaView { @@ -101,7 +103,8 @@ extension MediaView { titleLabelOverlay(with: ImageView.DefaultPlaceholderView(blurHash: imageSource.blurHash)) } .failure { - ImageView.DefaultFailureView() + Color.secondarySystemFill + .opacity(0.75) .overlay { titleLabel .foregroundColor(.primary) @@ -110,6 +113,7 @@ extension MediaView { .id(imageSources.hashValue) } .posterStyle(.landscape) + .posterShadow() } .onFirstAppear(perform: setImageSources) .onChange(of: useRandomImage) { _ in diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift index 89612350..b4e80529 100644 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift @@ -19,14 +19,14 @@ extension PagingLibraryView { private let item: Element private var onSelect: () -> Void - private let posterType: PosterType + private let posterType: PosterDisplayType private func imageView(from element: Element) -> ImageView { switch posterType { - case .portrait: - ImageView(element.portraitPosterImageSource(maxWidth: 60)) case .landscape: - ImageView(element.landscapePosterImageSources(maxWidth: 110, single: false)) + ImageView(element.landscapeImageSources(maxWidth: 110)) + case .portrait: + ImageView(element.portraitImageSources(maxWidth: 60)) } } @@ -75,13 +75,13 @@ extension PagingLibraryView { Button { onSelect() } label: { - HStack(alignment: .center, spacing: EdgeInsets.defaultEdgePadding) { + HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { ZStack { Color.clear imageView(from: item) .failure { - SystemImageContentView(systemName: item.typeSystemImage) + SystemImageContentView(systemName: item.systemImage) } } .posterStyle(posterType) @@ -122,7 +122,7 @@ extension PagingLibraryView { extension PagingLibraryView.LibraryRow { - init(item: Element, posterType: PosterType) { + init(item: Element, posterType: PosterDisplayType) { self.init( item: item, onSelect: {}, diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift index d01b0054..eb48970d 100644 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift +++ b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift @@ -16,13 +16,13 @@ extension PagingLibraryView { @Binding private var listColumnCount: Int @Binding - private var posterType: PosterType + private var posterType: PosterDisplayType @Binding - private var viewType: LibraryViewType + private var viewType: LibraryDisplayType init( - posterType: Binding, - viewType: Binding, + posterType: Binding, + viewType: Binding, listColumnCount: Binding ) { self._listColumnCount = listColumnCount @@ -62,7 +62,7 @@ extension PagingLibraryView { if viewType == .grid { Label("Grid", systemImage: "checkmark") } else { - Label("Grid", systemImage: "square.grid.2x2") + Label("Grid", systemImage: "square.grid.2x2.fill") } } @@ -83,7 +83,7 @@ extension PagingLibraryView { } label: { switch viewType { case .grid: - Label("Layout", systemImage: "square.grid.2x2") + Label("Layout", systemImage: "square.grid.2x2.fill") case .list: Label("Layout", systemImage: "square.fill.text.grid.1x2") } diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift index 5786937b..ba1b46eb 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -11,6 +11,10 @@ import Defaults import JellyfinAPI import SwiftUI +// TODO: need to think about better design for views that may not support current library display type +// - ex: channels/albums when in portrait/landscape +// - just have the supported view embedded in a container view? + // Note: Currently, it is a conscious decision to not have grid posters have subtitle content. // This is due to episodes, which have their `S_E_` subtitles, and these can be alongside // other items that don't have a subtitle which requires the entire library to implement @@ -98,8 +102,8 @@ struct PagingLibraryView: View { // MARK: layout private static func padLayout( - posterType: PosterType, - viewType: LibraryViewType, + posterType: PosterDisplayType, + viewType: LibraryDisplayType, listColumnCount: Int ) -> CollectionVGridLayout { switch (posterType, viewType) { @@ -113,8 +117,8 @@ struct PagingLibraryView: View { } private static func phoneLayout( - posterType: PosterType, - viewType: LibraryViewType + posterType: PosterDisplayType, + viewType: LibraryDisplayType ) -> CollectionVGridLayout { switch (posterType, viewType) { case (.landscape, .grid): @@ -128,6 +132,9 @@ struct PagingLibraryView: View { // MARK: item view + // Note: if parent is a folders then other items will have labels, + // so an empty content view is necessary + private func landscapeGridItemView(item: Element) -> some View { PosterButton(item: item, type: .landscape) .content { @@ -135,6 +142,11 @@ struct PagingLibraryView: View { PosterButton.TitleContentView(item: item) .backport .lineLimit(1, reservesSpace: true) + } else if viewModel.parent?.libraryType == .folder { + PosterButton.TitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) + .hidden() } } .onSelect { @@ -149,6 +161,11 @@ struct PagingLibraryView: View { PosterButton.TitleContentView(item: item) .backport .lineLimit(1, reservesSpace: true) + } else if viewModel.parent?.libraryType == .folder { + PosterButton.TitleContentView(item: item) + .backport + .lineLimit(1, reservesSpace: true) + .hidden() } } .onSelect { @@ -291,7 +308,11 @@ struct PagingLibraryView: View { Menu { - LibraryViewTypeToggle(posterType: $posterType, viewType: $viewType, listColumnCount: $listColumnCount) + LibraryViewTypeToggle( + posterType: $posterType, + viewType: $viewType, + listColumnCount: $listColumnCount + ) Button(L10n.random, systemImage: "dice.fill") { viewModel.send(.getRandomItem) diff --git a/Swiftfin/Views/ProgramsView/ProgramsView.swift b/Swiftfin/Views/ProgramsView/ProgramsView.swift index d669ffbf..9e598097 100644 --- a/Swiftfin/Views/ProgramsView/ProgramsView.swift +++ b/Swiftfin/Views/ProgramsView/ProgramsView.swift @@ -103,7 +103,7 @@ struct ProgramsView: View { @ViewBuilder private func programsSection( title: String, - keyPath: KeyPath + keyPath: KeyPath ) -> some View { PosterHStack( title: title, @@ -111,16 +111,15 @@ struct ProgramsView: View { items: programsViewModel[keyPath: keyPath] ) .content { - ProgramButtonContent(program: $0.programs[0]) + ProgramButtonContent(program: $0) } .imageOverlay { - ProgramProgressOverlay(program: $0.programs[0]) + ProgramProgressOverlay(program: $0) } - .onSelect { channelProgram in - guard let mediaSource = channelProgram.channel.mediaSources?.first else { return } + .onSelect { mainRouter.route( to: \.liveVideoPlayer, - LiveVideoPlayerManager(item: channelProgram.channel, mediaSource: mediaSource) + LiveVideoPlayerManager(program: $0) ) } } diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift index 1c004bfe..d5bd1c90 100644 --- a/Swiftfin/Views/SearchView.swift +++ b/Swiftfin/Views/SearchView.swift @@ -110,7 +110,7 @@ struct SearchView: View { private func itemsSection( title: String, keyPath: KeyPath, - posterType: PosterType + posterType: PosterDisplayType ) -> some View { PosterHStack( title: title, diff --git a/Swiftfin/Views/UserSignInView/UserSignInView.swift b/Swiftfin/Views/UserSignInView/UserSignInView.swift index 6cb512a1..c816f161 100644 --- a/Swiftfin/Views/UserSignInView/UserSignInView.swift +++ b/Swiftfin/Views/UserSignInView/UserSignInView.swift @@ -10,6 +10,7 @@ import Stinsen import SwiftUI struct UserSignInView: View { + @EnvironmentObject private var router: UserSignInCoordinator.Router diff --git a/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift b/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift index 2ccef396..b3a0e85a 100644 --- a/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift +++ b/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift @@ -139,12 +139,14 @@ class UILiveNativeVideoPlayerViewController: AVPlayerViewController { } private func createMetadata() -> [AVMetadataItem] { - let allMetadata: [AVMetadataIdentifier: Any?] = [ - .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, - .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, - ] + [] - return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } +// let allMetadata: [AVMetadataIdentifier: Any?] = [ +// .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, +// .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, +// ] +// +// return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } } private func createMetadataItem( diff --git a/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift index 7a81d438..9e46aa65 100644 --- a/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift +++ b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift @@ -53,15 +53,15 @@ extension LiveVideoPlayer.Overlay { .tint(Color.white) .foregroundColor(Color.white) - if let subtitle = viewModel.item.subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(.white) - .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in - dimensions[.leading] - } - .offset(y: -10) - } +// if let subtitle = viewModel.item.subtitle { +// Text(subtitle) +// .font(.subheadline) +// .foregroundColor(.white) +// .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in +// dimensions[.leading] +// } +// .offset(y: -10) +// } } } } diff --git a/Swiftfin/Views/VideoPlayer/NativeVideoPlayer.swift b/Swiftfin/Views/VideoPlayer/NativeVideoPlayer.swift index 93f78c98..b64449e3 100644 --- a/Swiftfin/Views/VideoPlayer/NativeVideoPlayer.swift +++ b/Swiftfin/Views/VideoPlayer/NativeVideoPlayer.swift @@ -143,12 +143,14 @@ class UINativeVideoPlayerViewController: AVPlayerViewController { } private func createMetadata() -> [AVMetadataItem] { - let allMetadata: [AVMetadataIdentifier: Any?] = [ - .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, - .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, - ] + [] - return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } +// let allMetadata: [AVMetadataIdentifier: Any?] = [ +// .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, +// .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, +// ] +// +// return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } } private func createMetadataItem( diff --git a/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift b/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift index 446d7963..3d5a8040 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/ChapterOverlay.swift @@ -38,6 +38,9 @@ extension VideoPlayer.Overlay { @EnvironmentObject private var viewModel: VideoPlayerViewModel + @State + private var size: CGSize = .zero + @StateObject private var collectionHStackProxy: CollectionHStackProxy = .init() @@ -75,7 +78,7 @@ extension VideoPlayer.Overlay { ) { chapter in ChapterButton(chapter: chapter) } - .insets(horizontal: EdgeInsets.defaultEdgePadding, vertical: EdgeInsets.defaultEdgePadding) + .insets(horizontal: EdgeInsets.edgePadding, vertical: EdgeInsets.edgePadding) .proxy(collectionHStackProxy) .onChange(of: currentOverlayType) { newValue in guard newValue == .chapters else { return } @@ -84,6 +87,8 @@ extension VideoPlayer.Overlay { collectionHStackProxy.scrollTo(element: currentChapter, animated: false) } } + .trackingSize($size) + .id(size.width) } .background { LinearGradient( @@ -132,20 +137,32 @@ extension VideoPlayer.Overlay.ChapterOverlay { ZStack { Color.black - ImageView(chapter.landscapePosterImageSources(maxWidth: 500, single: false)) + ImageView(chapter.landscapeImageSources(maxWidth: 500)) .failure { - SystemImageContentView(systemName: chapter.typeSystemImage) + SystemImageContentView(systemName: chapter.systemImage) } .aspectRatio(contentMode: .fit) } - .posterStyle(.landscape) .overlay { - if chapter.secondsRange.contains(currentProgressHandler.seconds) { - RoundedRectangle(cornerRadius: 1) - .stroke(accentColor, lineWidth: 5) - .transition(.opacity.animation(.linear(duration: 0.1))) - } + modifier(OnSizeChangedModifier { size in + if chapter.secondsRange.contains(currentProgressHandler.seconds) { + RoundedRectangle(cornerRadius: size.width * (1 / 30)) + .stroke(accentColor, lineWidth: 8) + .transition(.opacity.animation(.linear(duration: 0.1))) + .clipped() + } else { + RoundedRectangle(cornerRadius: size.width * (1 / 30)) + .stroke( + .white.opacity(0.10), + lineWidth: 1.5 + ) + .clipped() + } + }) } + .aspectRatio(1.77, contentMode: .fill) + .posterBorder(ratio: 1 / 30, of: \.width) + .cornerRadius(ratio: 1 / 30, of: \.width) VStack(alignment: .leading, spacing: 5) { Text(chapter.chapterInfo.displayTitle) diff --git a/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift b/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift index ff3ca653..96518f99 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/Components/TopBarView.swift @@ -54,15 +54,15 @@ extension VideoPlayer.Overlay { .tint(Color.white) .foregroundColor(Color.white) - if let subtitle = viewModel.item.subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(.white) - .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in - dimensions[.leading] - } - .offset(y: -10) - } +// if let subtitle = viewModel.item.subtitle { +// Text(subtitle) +// .font(.subheadline) +// .foregroundColor(.white) +// .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in +// dimensions[.leading] +// } +// .offset(y: -10) +// } } } }