Poster Display and Button Refactor (#1038)

This commit is contained in:
Ethan Pippin 2024-04-23 11:22:07 -06:00 committed by GitHub
parent ad8f4bbefd
commit 384e80805e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 999 additions and 600 deletions

View File

@ -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)
}
}
}

View File

@ -14,24 +14,24 @@ 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
}
var body: some View {
ZStack {
backgroundColor
.opacity(0.5)
private var imageView: some View {
Image(systemName: systemName)
.resizable()
.aspectRatio(contentMode: .fit)
@ -39,7 +39,33 @@ struct SystemImageContentView: View {
.accessibilityHidden(true)
.frame(width: contentSize.width / widthRatio, height: contentSize.height / heightRatio)
}
.size($contentSize)
@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)
imageView
.frame(width: contentSize.width)
.overlay(alignment: .bottom) {
label
.padding(.horizontal, 4)
.offset(y: labelSize.height)
}
}
.trackingSize($contentSize)
}
}

View File

@ -57,7 +57,7 @@ final class ItemCoordinator: NavigationCoordinatable {
}
func makeCastAndCrew(people: [BaseItemPerson]) -> LibraryCoordinator<BaseItemPerson> {
let viewModel = PagingLibraryViewModel(people, parent: BaseItemDto(name: L10n.castAndCrew))
let viewModel = PagingLibraryViewModel(title: L10n.castAndCrew, people)
return LibraryCoordinator(viewModel: viewModel)
}

View File

@ -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]
}
}

View File

@ -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)

View File

@ -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
)
}
}

View File

@ -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)]
}
}
}

View File

@ -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

View File

@ -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
)]
}
}

View File

@ -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]
}
}

View File

@ -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

View File

@ -26,6 +26,6 @@ extension UserDto {
let profileImageURL = client.fullURL(with: request)
return ImageSource(url: profileImageURL, blurHash: nil)
return ImageSource(url: profileImageURL)
}
}

View File

@ -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)

View File

@ -8,6 +8,8 @@
import SwiftUI
// TODO: remove as a `ViewModifier` and instead a wrapper view
struct AttributeViewModifier: ViewModifier {
enum Style {

View File

@ -34,7 +34,7 @@ struct BackgroundParallaxHeaderModifier<Header: View>: ViewModifier {
func body(content: Content) -> some View {
content
.size($contentSize)
.trackingSize($contentSize)
.background(alignment: .top) {
header()
.offset(y: scrollViewOffset > 0 ? -scrollViewOffset * multiplier : 0)

View File

@ -8,7 +8,7 @@
import SwiftUI
struct ScenePhaseChangeModifier: ViewModifier {
struct OnScenePhaseChangedModifier: ViewModifier {
@Environment(\.scenePhase)
private var scenePhase

View File

@ -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<Wrapped: View>: ViewModifier {
@State
private var size: CGSize = .zero
@ViewBuilder
var wrapped: (CGSize) -> Wrapped
func body(content: Content) -> some View {
wrapped(size)
.trackingSize($size)
}
}

View File

@ -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<CGSize, CGFloat>
func body(content: Content) -> some View {
content
.cornerRadius(cornerRadius, corners: corners)
.onSizeChanged { newSize in
cornerRadius = newSize[keyPath: side] * ratio
}
}
}

View File

@ -8,7 +8,7 @@
import SwiftUI
struct AfterLastDisappearModifier: ViewModifier {
struct SinceLastDisappearModifier: ViewModifier {
@State
private var lastDisappear: Date? = nil

View File

@ -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) {}
}

View File

@ -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<CGSize, CGFloat>) -> 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<CGSize, CGFloat>, 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<CGRect>) -> some View {
func trackingFrame(_ binding: Binding<CGRect>) -> 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<CGPoint>) -> some View {
func trackingLocation(_ binding: Binding<CGPoint>) -> 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<CGSize>) -> some View {
func trackingSize(_ binding: Binding<CGSize>) -> 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<Self> {
@ -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 {

View File

@ -37,6 +37,9 @@ struct CaseIterablePicker<Element: CaseIterable & Displayable & Hashable>: 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<Element: CaseIterable & Displayable & Hashable>: 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<Element?>) {
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<Element>) {
self.title = title
let binding = Binding<Element?> {
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<Element?>) {
self.init(
selection: selection,
label: { Label($0.displayTitle, systemImage: $0.systemImage) },
title: title,
hasNone: true,
noneStyle: .text
)
}
init(title: String, selection: Binding<Element>) {
let binding = Binding<Element?> {
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
)
}
}

View File

@ -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
}
}

View File

@ -8,6 +8,8 @@
import Foundation
/// A type that is displayed with a title
protocol Displayable {
var displayTitle: String { get }
}

View File

@ -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?

View File

@ -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"
}
}
}

View File

@ -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] {
[]
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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 }
}

View File

@ -34,15 +34,15 @@ extension Defaults.Keys {
static let itemViewType = Key<ItemViewType>("itemViewType", default: .compactLogo, suite: .generalSuite)
static let showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: .generalSuite)
static let nextUpPosterType = Key<PosterType>("nextUpPosterType", default: .portrait, suite: .generalSuite)
static let recentlyAddedPosterType = Key<PosterType>("recentlyAddedPosterType", default: .portrait, suite: .generalSuite)
static let latestInLibraryPosterType = Key<PosterType>("latestInLibraryPosterType", default: .portrait, suite: .generalSuite)
static let nextUpPosterType = Key<PosterDisplayType>("nextUpPosterType", default: .portrait, suite: .generalSuite)
static let recentlyAddedPosterType = Key<PosterDisplayType>("recentlyAddedPosterType", default: .portrait, suite: .generalSuite)
static let latestInLibraryPosterType = Key<PosterDisplayType>("latestInLibraryPosterType", default: .portrait, suite: .generalSuite)
static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", default: true, suite: .generalSuite)
static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: .generalSuite)
static let similarPosterType = Key<PosterType>("similarPosterType", default: .portrait, suite: .generalSuite)
static let similarPosterType = Key<PosterDisplayType>("similarPosterType", default: .portrait, suite: .generalSuite)
// TODO: have search poster type by types of items if applicable
static let searchPosterType = Key<PosterType>("searchPosterType", default: .portrait, suite: .generalSuite)
static let searchPosterType = Key<PosterDisplayType>("searchPosterType", default: .portrait, suite: .generalSuite)
enum CinematicItemViewType {
@ -74,12 +74,12 @@ extension Defaults.Keys {
default: ItemFilterType.allCases,
suite: .generalSuite
)
static let viewType = Key<LibraryViewType>(
static let viewType = Key<LibraryDisplayType>(
"libraryViewType",
default: .grid,
suite: .generalSuite
)
static let posterType = Key<PosterType>(
static let posterType = Key<PosterDisplayType>(
"libraryPosterType",
default: .portrait,
suite: .generalSuite

View File

@ -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<Element: Poster>: 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()
}

View File

@ -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 ?? []
}
}

View File

@ -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

View File

@ -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

View File

@ -10,8 +10,6 @@ import Combine
import JellyfinAPI
import SwiftUI
// TODO: better name
struct CinematicBackgroundView<Item: Poster>: View {
@ObservedObject
@ -26,8 +24,8 @@ struct CinematicBackgroundView<Item: Poster>: 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<Item: Poster>: View {
init() {
currentItemSubject
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.removeDuplicates()
.sink { newItem in
self.currentItem = newItem
}
@ -56,7 +55,6 @@ struct CinematicBackgroundView<Item: Poster>: View {
}
func select(item: Item) {
guard currentItem != item else { return }
currentItemSubject.send(item)
}
}

View File

@ -10,6 +10,7 @@ import Combine
import JellyfinAPI
import SwiftUI
// TODO: make new protocol for cinematic view image provider
// TODO: better name
struct CinematicItemSelector<Item: Poster>: View {
@ -56,7 +57,10 @@ struct CinematicItemSelector<Item: Poster>: View {
}
.background(alignment: .top) {
ZStack {
CinematicBackgroundView(viewModel: viewModel, initialItem: items.first)
CinematicBackgroundView(
viewModel: viewModel,
initialItem: items.first
)
LinearGradient(
stops: [

View File

@ -10,7 +10,7 @@ import SwiftUI
struct NonePosterButton: View {
let type: PosterType
let type: PosterDisplayType
var body: some View {
Button {

View File

@ -88,8 +88,8 @@ struct PagingLibraryView<Element: Poster>: View {
// MARK: layout
private static func makeLayout(
posterType: PosterType,
viewType: LibraryViewType
posterType: PosterDisplayType,
viewType: LibraryDisplayType
) -> CollectionVGridLayout {
switch (posterType, viewType) {
case (.landscape, .grid):

View File

@ -18,13 +18,12 @@ struct PosterButton<Item: Poster>: 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<Item: Poster>: 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<Item: Poster>: 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<Item: Poster>: 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 {

View File

@ -15,7 +15,7 @@ import SwiftUI
struct PosterHStack<Item: Poster>: View {
private var title: String?
private var type: PosterType
private var type: PosterDisplayType
private var items: Binding<OrderedSet<Item>>
private var content: (Item) -> any View
private var imageOverlay: (Item) -> any View
@ -58,8 +58,8 @@ struct PosterHStack<Item: Poster>: 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<OrderedSet<Item>>
) {
self.init(
@ -103,7 +103,7 @@ extension PosterHStack {
init<S: Sequence<Item>>(
title: String? = nil,
type: PosterType,
type: PosterDisplayType,
items: S
) {
self.init(

View File

@ -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: {}

View File

@ -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)

View File

@ -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)

View File

@ -39,7 +39,7 @@ extension HomeView {
CinematicItemSelector(items: viewModel.elements.elements)
.topContent { item in
ImageView(itemSelectorImageSource(for: item))
.placeholder {
.placeholder { _ in
EmptyView()
}
.failure {

View File

@ -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<BaseItemDto>.EpisodeContentSubtitleContent.Subtitle(item: item)
} else {
Text(" ")
}
}
.itemImageOverlay { item in

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -67,7 +67,7 @@ extension ItemView {
maxWidth: UIScreen.main.bounds.width * 0.4,
maxHeight: 250
))
.placeholder {
.placeholder { _ in
EmptyView()
}
.failure {

View File

@ -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)

View File

@ -54,7 +54,7 @@ struct ProgramsView: View {
@ViewBuilder
private func programsSection(
title: String,
keyPath: KeyPath<ProgramsViewModel, [ChannelProgram]>
keyPath: KeyPath<ProgramsViewModel, [BaseItemDto]>
) -> 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 {

View File

@ -97,7 +97,7 @@ struct SearchView: View {
private func itemsSection(
title: String,
keyPath: KeyPath<SearchViewModel, [BaseItemDto]>,
posterType: PosterType
posterType: PosterDisplayType
) -> some View {
PosterHStack(
title: title,

View File

@ -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)

View File

@ -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)

View File

@ -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 = "<group>"; };
E10231312BCF8A3C009D71FC /* ProgramProgressOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramProgressOverlay.swift; sourceTree = "<group>"; };
E10231332BCF8A3C009D71FC /* ProgramsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramsView.swift; sourceTree = "<group>"; };
E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WideChannelGridItem.swift; sourceTree = "<group>"; };
E10231352BCF8A3C009D71FC /* DetailedChannelView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailedChannelView.swift; sourceTree = "<group>"; };
E10231372BCF8A3C009D71FC /* ChannelLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryView.swift; sourceTree = "<group>"; };
E10231432BCF8A51009D71FC /* ChannelProgram.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelProgram.swift; sourceTree = "<group>"; };
E10231462BCF8A6D009D71FC /* ChannelLibraryViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelLibraryViewModel.swift; sourceTree = "<group>"; };
@ -999,6 +1004,8 @@
E104DC952B9E7E29008F506D /* AssertionFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertionFailureView.swift; sourceTree = "<group>"; };
E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemViewModel.swift; sourceTree = "<group>"; };
E1092F4B29106F9F00163F57 /* GestureAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureAction.swift; sourceTree = "<group>"; };
E10B1E892BD76FA900A92EAF /* QuickConnectViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickConnectViewModel.swift; sourceTree = "<group>"; };
E10B1E8C2BD7708900A92EAF /* QuickConnectView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = "<group>"; };
E10E842929A587110064EA49 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
E10E842B29A589860064EA49 /* NonePosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonePosterButton.swift; sourceTree = "<group>"; };
E10EAA4E277BBCC4000269ED /* CGSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSize.swift; sourceTree = "<group>"; };
@ -1060,7 +1067,7 @@
E12E30F229638B140022FAC9 /* ChevronButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronButton.swift; sourceTree = "<group>"; };
E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumPickerView.swift; sourceTree = "<group>"; };
E12F038B28F8B0B100976CC3 /* EdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeInsets.swift; sourceTree = "<group>"; };
E13316FD2ADE42B6009BF865 /* RatioCornerRadiusModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatioCornerRadiusModifier.swift; sourceTree = "<group>"; };
E13316FD2ADE42B6009BF865 /* OnSizeChangedModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnSizeChangedModifier.swift; sourceTree = "<group>"; };
E133328729538D8D00EE76AB /* Files.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Files.swift; sourceTree = "<group>"; };
E133328C2953AE4B00EE76AB /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = "<group>"; };
E133328E2953B71000EE76AB /* DownloadTaskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadTaskView.swift; sourceTree = "<group>"; };
@ -1083,7 +1090,7 @@
E13DD3F82717E961009D4DAF /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = "<group>"; };
E13DD3FB2717EAE8009D4DAF /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListCoordinator.swift; sourceTree = "<group>"; };
E13F05EB28BC9000003499D2 /* LibraryViewType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryViewType.swift; sourceTree = "<group>"; };
E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryDisplayType.swift; sourceTree = "<group>"; };
E13F05EF28BC9016003499D2 /* LibraryRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryRow.swift; sourceTree = "<group>"; };
E1401C9F2937DFF500E8B599 /* AppIconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconSelectorView.swift; sourceTree = "<group>"; };
E1401CA12938122C00E8B599 /* AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcons.swift; sourceTree = "<group>"; };
@ -1125,6 +1132,8 @@
E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceInfoView.swift; sourceTree = "<group>"; };
E15D4F062B1B12C300442DB8 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
E15D4F092B1BD88900442DB8 /* Edge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Edge.swift; sourceTree = "<group>"; };
E15D63EC2BD622A700AA665D /* CompactChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactChannelView.swift; sourceTree = "<group>"; };
E15D63EE2BD6DFC200AA665D /* SystemImageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageable.swift; sourceTree = "<group>"; };
E168BD08289A4162001A6922 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = "<group>"; };
E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = "<group>"; };
@ -1237,6 +1246,7 @@
E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = "<group>"; };
E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = "<group>"; };
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = "<group>"; };
E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = "<group>"; };
E1AA331C2782541500F6439C /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = "<group>"; };
E1AA331E2782639D00F6439C /* OverlayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayType.swift; sourceTree = "<group>"; };
E1AD104C26D96CE3003E4A08 /* BaseItemDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDto.swift; sourceTree = "<group>"; };
@ -1285,7 +1295,7 @@
E1CAF65B2BA345830087D991 /* MediaViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaViewModel.swift; sourceTree = "<group>"; };
E1CAF6612BA363840087D991 /* UIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIHostingController.swift; sourceTree = "<group>"; };
E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileButton.swift; sourceTree = "<group>"; };
E1CCF12D28ABF989006CAC9E /* PosterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterType.swift; sourceTree = "<group>"; };
E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterDisplayType.swift; sourceTree = "<group>"; };
E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = "<group>"; };
E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectOrientationModifier.swift; sourceTree = "<group>"; };
E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
@ -1334,7 +1344,7 @@
E1E1E24C28DF8A2E000DF5FD /* PreferenceKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceKeys.swift; sourceTree = "<group>"; };
E1E2F83E2B757DFA00B75998 /* OnFinalDisappearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFinalDisappearModifier.swift; sourceTree = "<group>"; };
E1E2F8412B757E0900B75998 /* OnFirstAppearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFirstAppearModifier.swift; sourceTree = "<group>"; };
E1E2F8442B757E3400B75998 /* AfterLastDisappearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AfterLastDisappearModifier.swift; sourceTree = "<group>"; };
E1E2F8442B757E3400B75998 /* SinceLastDisappearModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SinceLastDisappearModifier.swift; sourceTree = "<group>"; };
E1E306CC28EF6E8000537998 /* TimerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerProxy.swift; sourceTree = "<group>"; };
E1E5D5472783CCF900692DFE /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = "<group>"; };
E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = "<group>"; };
@ -1371,9 +1381,7 @@
E1FCD09526C47118007C8DCF /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = "<group>"; };
E1FE69A628C29B720021BC93 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = "<group>"; };
E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = "<group>"; };
E43918652AD5C8310045A18C /* ScenePhaseChangeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenePhaseChangeModifier.swift; sourceTree = "<group>"; };
EA2073A02BAE35A400D8C78F /* QuickConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectViewModel.swift; sourceTree = "<group>"; };
EADD26FC2BAE4A6C002F05DE /* QuickConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = "<group>"; };
E43918652AD5C8310045A18C /* OnScenePhaseChangedModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnScenePhaseChangedModifier.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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 = "<group>";
};
E10231362BCF8A3C009D71FC /* Component */ = {
E10231362BCF8A3C009D71FC /* Components */ = {
isa = PBXGroup;
children = (
E10231352BCF8A3C009D71FC /* WideChannelGridItem.swift */,
E15D63EC2BD622A700AA665D /* CompactChannelView.swift */,
E10231352BCF8A3C009D71FC /* DetailedChannelView.swift */,
);
path = Component;
path = Components;
sourceTree = "<group>";
};
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 = "<group>";
@ -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 = "<group>";
@ -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 */,

View File

@ -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<Item: Poster>: 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<Item: Poster>: 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<Item: Poster>: 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 {
@ -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 {

View File

@ -14,9 +14,8 @@ struct PosterHStack<Item: Poster>: View {
private var header: () -> any View
private var title: String?
private var type: PosterType
private var type: PosterDisplayType
private var items: Binding<OrderedSet<Item>>
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<Item: Poster>: 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<Item: Poster>: 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<Item: Poster>: 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<Item: Poster>: 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<OrderedSet<Item>>,
singleImage: Bool = false
type: PosterDisplayType,
items: Binding<OrderedSet<Item>>
) {
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<S: Sequence<Item>>(
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))
)
}

View File

@ -12,36 +12,69 @@ 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)
}
}
private var contentView: some View {
CollectionVGrid(
$viewModel.elements,
layout: layout
) { channel in
WideChannelGridItem(channel: channel)
// 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(
@ -50,6 +83,30 @@ struct ChannelLibraryView: View {
)
}
}
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
) { channel in
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
)
}
}
}
}

View File

@ -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)
}
}

View File

@ -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(

View File

@ -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)

View File

@ -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))

View File

@ -31,10 +31,11 @@ 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
@ -47,6 +48,12 @@ extension HomeView {
.onSelect { item in
router.route(to: \.item, item)
}
.trailing {
SeeAllButton()
.onSelect {
router.route(to: \.library, homeViewModel.nextUpViewModel)
}
}
}
}
}

View File

@ -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)

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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)

View File

@ -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 {

View File

@ -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

View File

@ -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: {},

View File

@ -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<PosterType>,
viewType: Binding<LibraryViewType>,
posterType: Binding<PosterDisplayType>,
viewType: Binding<LibraryDisplayType>,
listColumnCount: Binding<Int>
) {
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")
}

View File

@ -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<Element: Poster>: 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<Element: Poster>: 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<Element: Poster>: 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<Element: Poster>: 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<Element: Poster>: 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<Element: Poster>: 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)

View File

@ -103,7 +103,7 @@ struct ProgramsView: View {
@ViewBuilder
private func programsSection(
title: String,
keyPath: KeyPath<ProgramsViewModel, [ChannelProgram]>
keyPath: KeyPath<ProgramsViewModel, [BaseItemDto]>
) -> 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)
)
}
}

View File

@ -110,7 +110,7 @@ struct SearchView: View {
private func itemsSection(
title: String,
keyPath: KeyPath<SearchViewModel, [BaseItemDto]>,
posterType: PosterType
posterType: PosterDisplayType
) -> some View {
PosterHStack(
title: title,

View File

@ -10,6 +10,7 @@ import Stinsen
import SwiftUI
struct UserSignInView: View {
@EnvironmentObject
private var router: UserSignInCoordinator.Router

View File

@ -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(

View File

@ -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)
// }
}
}
}

View File

@ -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(

View File

@ -38,6 +38,9 @@ extension VideoPlayer.Overlay {
@EnvironmentObject
private var viewModel: VideoPlayerViewModel
@State
private var size: CGSize = .zero
@StateObject
private var collectionHStackProxy: CollectionHStackProxy<ChapterInfo.FullInfo> = .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 {
modifier(OnSizeChangedModifier { size in
if chapter.secondsRange.contains(currentProgressHandler.seconds) {
RoundedRectangle(cornerRadius: 1)
.stroke(accentColor, lineWidth: 5)
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)

View File

@ -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)
// }
}
}
}