Some work (#552)
This commit is contained in:
parent
1fe2f1d30c
commit
79476328fe
|
@ -38,7 +38,8 @@ final class MainCoordinator: NavigationCoordinatable {
|
|||
UIScrollView.appearance().keyboardDismissMode = .onDrag
|
||||
|
||||
// Back bar button item setup
|
||||
let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill")
|
||||
let config = UIImage.SymbolConfiguration(paletteColors: [.white, .jellyfinPurple])
|
||||
let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill", withConfiguration: config)
|
||||
let barAppearance = UINavigationBar.appearance()
|
||||
barAppearance.backIndicatorImage = backButtonBackgroundImage
|
||||
barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage
|
||||
|
|
|
@ -42,19 +42,19 @@ extension BaseItemDto {
|
|||
return text
|
||||
}
|
||||
|
||||
func getItemProgressString() -> String? {
|
||||
if userData?.playbackPositionTicks == nil || userData?.playbackPositionTicks == 0 {
|
||||
return nil
|
||||
}
|
||||
var progress: String? {
|
||||
guard let playbackPositionTicks = userData?.playbackPositionTicks,
|
||||
let totalTicks = runTimeTicks,
|
||||
playbackPositionTicks != 0,
|
||||
totalTicks != 0 else { return nil }
|
||||
|
||||
let remainingSecs = ((runTimeTicks ?? 0) - (userData?.playbackPositionTicks ?? 0)) / 10_000_000
|
||||
let proghours = Int(remainingSecs / 3600)
|
||||
let progminutes = Int((Int(remainingSecs) - (proghours * 3600)) / 60)
|
||||
if proghours != 0 {
|
||||
return "\(proghours)h \(String(progminutes).leftPad(toWidth: 2, withString: "0"))m"
|
||||
} else {
|
||||
return "\(String(progminutes))m"
|
||||
}
|
||||
let remainingSeconds = (totalTicks - playbackPositionTicks) / 10_000_000
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.hour, .minute]
|
||||
formatter.unitsStyle = .abbreviated
|
||||
|
||||
return formatter.string(from: .init(remainingSeconds))
|
||||
}
|
||||
|
||||
func getLiveStartTimeString(formatter: DateFormatter) -> String {
|
||||
|
|
|
@ -46,6 +46,9 @@ extension View {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: Simplify plethora of calls
|
||||
// TODO: Centralize math
|
||||
// TODO: Move poster stuff to own file
|
||||
func posterStyle(type: PosterType, width: CGFloat) -> some View {
|
||||
Group {
|
||||
switch type {
|
||||
|
@ -57,17 +60,35 @@ extension View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Applies Portrait Poster frame with proper corner radius ratio against the width
|
||||
func portraitPoster(width: CGFloat) -> some View {
|
||||
func posterStyle(type: PosterType, height: CGFloat) -> some View {
|
||||
Group {
|
||||
switch type {
|
||||
case .portrait:
|
||||
self.portraitPoster(height: height)
|
||||
case .landscape:
|
||||
self.landscapePoster(height: height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func portraitPoster(width: CGFloat) -> some View {
|
||||
self.frame(width: width, height: width * 1.5)
|
||||
.cornerRadius((width * 1.5) / 40)
|
||||
}
|
||||
|
||||
func landscapePoster(width: CGFloat) -> some View {
|
||||
private func landscapePoster(width: CGFloat) -> some View {
|
||||
self.frame(width: width, height: width / 1.77)
|
||||
.cornerRadius(width / 30)
|
||||
}
|
||||
|
||||
private func portraitPoster(height: CGFloat) -> some View {
|
||||
self.portraitPoster(width: height / 1.5)
|
||||
}
|
||||
|
||||
private func landscapePoster(height: CGFloat) -> some View {
|
||||
self.landscapePoster(width: height * 1.77)
|
||||
}
|
||||
|
||||
@inlinable
|
||||
func padding2(_ edges: Edge.Set = .all) -> some View {
|
||||
self.padding(edges)
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Defaults
|
||||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
@ -63,9 +64,10 @@ extension EpisodesRowManager {
|
|||
TvShowsAPI.getEpisodes(
|
||||
seriesId: item.id ?? "",
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
|
||||
fields: [.overview, .seasonUserData],
|
||||
seasonId: seasonID,
|
||||
isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false
|
||||
isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false,
|
||||
enableUserData: true
|
||||
)
|
||||
.trackActivity(loading)
|
||||
.sink { completion in
|
||||
|
|
|
@ -13,20 +13,13 @@ import Stinsen
|
|||
|
||||
final class EpisodeItemViewModel: ItemViewModel {
|
||||
|
||||
@RouterObject
|
||||
private var itemRouter: ItemCoordinator.Router?
|
||||
@Published
|
||||
var playButtonText: String = ""
|
||||
@Published
|
||||
var mediaDetailItems: [[BaseItemDto.ItemDetail]] = []
|
||||
var seriesItem: BaseItemDto?
|
||||
|
||||
override init(item: BaseItemDto) {
|
||||
super.init(item: item)
|
||||
|
||||
$videoPlayerViewModels.sink(receiveValue: { newValue in
|
||||
self.mediaDetailItems = self.createMediaDetailItems(viewModels: newValue)
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
getSeriesItem()
|
||||
}
|
||||
|
||||
override func updateItem() {
|
||||
|
@ -56,29 +49,23 @@ final class EpisodeItemViewModel: ItemViewModel {
|
|||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func createMediaDetailItems(viewModels: [VideoPlayerViewModel]) -> [[BaseItemDto.ItemDetail]] {
|
||||
var fileMediaItems: [[BaseItemDto.ItemDetail]] = []
|
||||
private func getSeriesItem() {
|
||||
guard let seriesID = item.seriesId else { return }
|
||||
|
||||
for viewModel in viewModels {
|
||||
|
||||
let audioStreams = viewModel.audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
|
||||
.joined(separator: ", ")
|
||||
|
||||
let subtitleStreams = viewModel.subtitleStreams
|
||||
.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
|
||||
.joined(separator: ", ")
|
||||
|
||||
let currentMediaItems: [BaseItemDto.ItemDetail] = [
|
||||
.init(title: "File", content: viewModel.filename ?? .emptyDash),
|
||||
.init(title: "Audio", content: audioStreams),
|
||||
.init(title: "Subtitles", content: subtitleStreams),
|
||||
]
|
||||
|
||||
fileMediaItems.append(currentMediaItems)
|
||||
}
|
||||
|
||||
// print(fileMediaItems)
|
||||
|
||||
return fileMediaItems
|
||||
ItemsAPI.getItems(
|
||||
userId: SessionManager.main.currentLogin.user.id,
|
||||
limit: 1,
|
||||
fields: ItemFields.allCases,
|
||||
enableUserData: true,
|
||||
ids: [seriesID]
|
||||
)
|
||||
.trackActivity(loading)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
self?.handleAPIRequestError(completion: completion)
|
||||
}, receiveValue: { [weak self] response in
|
||||
guard let firstItem = response.items?.first else { return }
|
||||
self?.seriesItem = firstItem
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -70,8 +70,8 @@ class ItemViewModel: ViewModel {
|
|||
}
|
||||
|
||||
func refreshItemVideoPlayerViewModel(for item: BaseItemDto) {
|
||||
guard item.type == .episode || item.type == .movie else { return }
|
||||
guard !item.missing, !item.unaired else { return }
|
||||
guard item.type == .episode || item.type == .movie,
|
||||
!item.missing else { return }
|
||||
|
||||
item.createVideoPlayerViewModel()
|
||||
.sink { completion in
|
||||
|
@ -93,7 +93,7 @@ class ItemViewModel: ViewModel {
|
|||
return L10n.missing
|
||||
}
|
||||
|
||||
if let itemProgressString = item.getItemProgressString() {
|
||||
if let itemProgressString = item.progress {
|
||||
return itemProgressString
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
//
|
||||
// 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) 2022 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct LandscapePosterProgressBar: View {
|
||||
|
||||
let title: String
|
||||
let progress: CGFloat
|
||||
|
||||
// Scale padding depending on view width
|
||||
@State
|
||||
private var paddingScale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { reader in
|
||||
ZStack(alignment: .bottom) {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .black.opacity(0.7), location: 1),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 40)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3 * paddingScale) {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white)
|
||||
|
||||
ProgressBar(progress: progress)
|
||||
}
|
||||
.padding(.horizontal, 5 * paddingScale)
|
||||
.padding(.bottom, 7 * paddingScale)
|
||||
.onAppear {
|
||||
paddingScale = reader.size.width / 300
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// 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) 2022 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ProgressBar: View {
|
||||
|
||||
let progress: CGFloat
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.2)
|
||||
|
||||
Capsule()
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
.scaleEffect(x: progress, y: 1, anchor: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 3)
|
||||
}
|
||||
}
|
|
@ -47,7 +47,7 @@ struct CinematicResumeCardView: View {
|
|||
.ignoresSafeArea()
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.getItemProgressString() ?? "")
|
||||
Text(item.progress ?? "")
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 5)
|
||||
.padding(.leading, 10)
|
||||
|
|
|
@ -81,7 +81,7 @@ struct LandscapeItemElement: View {
|
|||
.frame(width: 445, height: 90)
|
||||
.mask(CutOffShadow())
|
||||
VStack(alignment: .leading) {
|
||||
Text("CONTINUE • \(item.getItemProgressString() ?? "")")
|
||||
Text("CONTINUE • \(item.progress ?? "")")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.offset(y: 5)
|
||||
|
|
|
@ -34,7 +34,7 @@ struct ContinueWatchingCard: View {
|
|||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.getItemProgressString() ?? "")
|
||||
Text(item.progress ?? "")
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 5)
|
||||
.padding(.leading, 10)
|
||||
|
|
|
@ -41,7 +41,7 @@ extension ItemView {
|
|||
.failure {
|
||||
InitialFailureView(viewModel.item.title.initials)
|
||||
}
|
||||
.portraitPoster(width: 270)
|
||||
.posterStyle(type: .portrait, width: 270)
|
||||
|
||||
AboutViewCard(
|
||||
isShowingAlert: $presentOverviewAlert,
|
||||
|
|
|
@ -525,6 +525,10 @@
|
|||
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD08726C35A0D007C8DCF /* NetworkError.swift */; };
|
||||
E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; };
|
||||
E1FCD09726C47118007C8DCF /* ErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FCD09526C47118007C8DCF /* ErrorMessage.swift */; };
|
||||
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 */; };
|
||||
E1FE69AB28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
@ -949,6 +953,8 @@
|
|||
E1FA891D289A305D00176FEB /* iPadOSCollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPadOSCollectionItemContentView.swift; sourceTree = "<group>"; };
|
||||
E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
@ -1446,6 +1452,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
E18E01A7288746AF0022598C /* DotHStack.swift */,
|
||||
E1FE69AF28C2DA4A0021BC93 /* FilterDrawerHStack */,
|
||||
E18E01A5288746AF0022598C /* PillHStack.swift */,
|
||||
E16AA60728A364A6009A983C /* PosterButton.swift */,
|
||||
E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */,
|
||||
|
@ -2055,7 +2062,9 @@
|
|||
E18E01FF288749200022598C /* Divider.swift */,
|
||||
531AC8BE26750DE20091C7EB /* ImageView.swift */,
|
||||
E1047E2227E5880000CB0D4A /* InitialFailureView.swift */,
|
||||
E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */,
|
||||
531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */,
|
||||
E1FE69A628C29B720021BC93 /* ProgressBar.swift */,
|
||||
E1E1643D28BB074000323B0A /* SelectorView.swift */,
|
||||
E1EBCB41278BD174009FE6E9 /* TruncatedTextView.swift */,
|
||||
);
|
||||
|
@ -2074,8 +2083,6 @@
|
|||
E1C55AB228BD051700A9AD88 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E113133528BE98AA00930F75 /* FilterDrawerButton.swift */,
|
||||
E113133328BE988200930F75 /* FilterDrawerHStack.swift */,
|
||||
E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */,
|
||||
);
|
||||
path = Components;
|
||||
|
@ -2190,6 +2197,15 @@
|
|||
path = Errors;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E1FE69AF28C2DA4A0021BC93 /* FilterDrawerHStack */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E113133528BE98AA00930F75 /* FilterDrawerButton.swift */,
|
||||
E113133328BE988200930F75 /* FilterDrawerHStack.swift */,
|
||||
);
|
||||
path = FilterDrawerHStack;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
@ -2484,6 +2500,7 @@
|
|||
E18E021A2887492B0022598C /* AppIcon.swift in Sources */,
|
||||
E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */,
|
||||
E126F742278A656C00A522BF /* ServerStreamType.swift in Sources */,
|
||||
E1FE69AB28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */,
|
||||
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
||||
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */,
|
||||
E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */,
|
||||
|
@ -2623,6 +2640,7 @@
|
|||
E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */,
|
||||
E1A2C160279A7DCA005EC829 /* AboutAppView.swift in Sources */,
|
||||
C4BE076A271FC164003F4AD1 /* TVLibrariesView.swift in Sources */,
|
||||
E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */,
|
||||
E1A16C9D2889AF1E00EA4679 /* AboutView.swift in Sources */,
|
||||
E11CEB8E28999B4A003E74C7 /* FontExtensions.swift in Sources */,
|
||||
C4BE077A2726EE82003F4AD1 /* LiveTVTabCoordinator.swift in Sources */,
|
||||
|
@ -2763,6 +2781,7 @@
|
|||
E13DD3E127176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
|
||||
E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
|
||||
62E632E9267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */,
|
||||
E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */,
|
||||
E113133228BDC72000930F75 /* FilterView.swift in Sources */,
|
||||
62E632F3267D54030063E547 /* ItemViewModel.swift in Sources */,
|
||||
E11895AC289383EE0042947B /* NavBarOffsetModifier.swift in Sources */,
|
||||
|
@ -2803,6 +2822,7 @@
|
|||
E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */,
|
||||
C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */,
|
||||
E184C160288C5C08000B25BA /* RequestBuilderExtensions.swift in Sources */,
|
||||
E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */,
|
||||
E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */,
|
||||
E18E01E9288747230022598C /* SeriesItemView.swift in Sources */,
|
||||
E18E023A288749540022598C /* UIScrollViewExtensions.swift in Sources */,
|
||||
|
|
|
@ -53,10 +53,10 @@ extension FilterDrawerHStack {
|
|||
.foregroundColor(activated ? .jellyfinPurple : Color(UIColor.secondarySystemFill))
|
||||
.opacity(0.5)
|
||||
}
|
||||
.overlay(
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(activated ? .purple : Color(UIColor.secondarySystemFill), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,15 +10,15 @@ import SwiftUI
|
|||
|
||||
struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View>: View {
|
||||
|
||||
private let item: Item
|
||||
private let type: PosterType
|
||||
private let itemScale: CGFloat
|
||||
private let horizontalAlignment: HorizontalAlignment
|
||||
private let content: (Item) -> Content
|
||||
private let imageOverlay: (Item) -> ImageOverlay
|
||||
private let contextMenu: (Item) -> ContextMenu
|
||||
private let onSelect: (Item) -> Void
|
||||
private let singleImage: Bool
|
||||
private var item: Item
|
||||
private var type: PosterType
|
||||
private var itemScale: CGFloat
|
||||
private var horizontalAlignment: HorizontalAlignment
|
||||
private var content: (Item) -> Content
|
||||
private var imageOverlay: (Item) -> ImageOverlay
|
||||
private var contextMenu: (Item) -> ContextMenu
|
||||
private var onSelect: (Item) -> Void
|
||||
private var singleImage: Bool
|
||||
|
||||
private var itemWidth: CGFloat {
|
||||
type.width * itemScale
|
||||
|
@ -96,34 +96,16 @@ extension PosterButton where Content == PosterButtonDefaultContentView<Item>,
|
|||
}
|
||||
|
||||
extension PosterButton {
|
||||
@ViewBuilder
|
||||
func horizontalAlignment(_ alignment: HorizontalAlignment) -> PosterButton {
|
||||
PosterButton(
|
||||
item: item,
|
||||
type: type,
|
||||
itemScale: itemScale,
|
||||
horizontalAlignment: alignment,
|
||||
content: content,
|
||||
imageOverlay: imageOverlay,
|
||||
contextMenu: contextMenu,
|
||||
onSelect: onSelect,
|
||||
singleImage: singleImage
|
||||
)
|
||||
func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self {
|
||||
var copy = self
|
||||
copy.horizontalAlignment = alignment
|
||||
return copy
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func scaleItem(_ scale: CGFloat) -> PosterButton {
|
||||
PosterButton(
|
||||
item: item,
|
||||
type: type,
|
||||
itemScale: scale,
|
||||
horizontalAlignment: horizontalAlignment,
|
||||
content: content,
|
||||
imageOverlay: imageOverlay,
|
||||
contextMenu: contextMenu,
|
||||
onSelect: onSelect,
|
||||
singleImage: singleImage
|
||||
)
|
||||
func scaleItem(_ scale: CGFloat) -> Self {
|
||||
var copy = self
|
||||
copy.itemScale = scale
|
||||
return copy
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -171,19 +153,10 @@ extension PosterButton {
|
|||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func onSelect(_ action: @escaping (Item) -> Void) -> PosterButton {
|
||||
PosterButton(
|
||||
item: item,
|
||||
type: type,
|
||||
itemScale: itemScale,
|
||||
horizontalAlignment: horizontalAlignment,
|
||||
content: content,
|
||||
imageOverlay: imageOverlay,
|
||||
contextMenu: contextMenu,
|
||||
onSelect: action,
|
||||
singleImage: singleImage
|
||||
)
|
||||
func onSelect(_ action: @escaping (Item) -> Void) -> Self {
|
||||
var copy = self
|
||||
copy.onSelect = action
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,19 +6,20 @@
|
|||
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import CollectionView
|
||||
import SwiftUI
|
||||
|
||||
struct PosterHStack<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View, TrailingContent: View>: View {
|
||||
|
||||
private let title: String
|
||||
private let type: PosterType
|
||||
private let items: [Item]
|
||||
private let itemScale: CGFloat
|
||||
private let content: (Item) -> Content
|
||||
private let imageOverlay: (Item) -> ImageOverlay
|
||||
private let contextMenu: (Item) -> ContextMenu
|
||||
private let trailingContent: () -> TrailingContent
|
||||
private let onSelect: (Item) -> Void
|
||||
private var title: String
|
||||
private var type: PosterType
|
||||
private var items: [Item]
|
||||
private var itemScale: CGFloat
|
||||
private var content: (Item) -> Content
|
||||
private var imageOverlay: (Item) -> ImageOverlay
|
||||
private var contextMenu: (Item) -> ContextMenu
|
||||
private var trailingContent: () -> TrailingContent
|
||||
private var onSelect: (Item) -> Void
|
||||
|
||||
private init(
|
||||
title: String,
|
||||
|
@ -103,19 +104,11 @@ extension PosterHStack where Content == PosterButtonDefaultContentView<Item>,
|
|||
}
|
||||
|
||||
extension PosterHStack {
|
||||
@ViewBuilder
|
||||
func scaleItems(_ scale: CGFloat) -> PosterHStack {
|
||||
PosterHStack(
|
||||
title: title,
|
||||
type: type,
|
||||
items: items,
|
||||
itemScale: scale,
|
||||
content: content,
|
||||
imageOverlay: imageOverlay,
|
||||
contextMenu: contextMenu,
|
||||
trailingContent: trailingContent,
|
||||
onSelect: onSelect
|
||||
)
|
||||
|
||||
func scaleItems(_ scale: CGFloat) -> Self {
|
||||
var copy = self
|
||||
copy.itemScale = scale
|
||||
return copy
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -182,18 +175,9 @@ extension PosterHStack {
|
|||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func onSelect(_ onSelect: @escaping (Item) -> Void) -> PosterHStack {
|
||||
PosterHStack(
|
||||
title: title,
|
||||
type: type,
|
||||
items: items,
|
||||
itemScale: itemScale,
|
||||
content: content,
|
||||
imageOverlay: imageOverlay,
|
||||
contextMenu: contextMenu,
|
||||
trailingContent: trailingContent,
|
||||
onSelect: onSelect
|
||||
)
|
||||
func onSelect(_ action: @escaping (Item) -> Void) -> Self {
|
||||
var copy = self
|
||||
copy.onSelect = action
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,35 +30,10 @@ struct ContinueWatchingView: View {
|
|||
}
|
||||
}
|
||||
.imageOverlay { item in
|
||||
VStack {
|
||||
|
||||
Spacer()
|
||||
|
||||
ZStack(alignment: .bottom) {
|
||||
|
||||
LinearGradient(
|
||||
colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 35)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.getItemProgressString() ?? L10n.continue)
|
||||
.font(.subheadline)
|
||||
.padding(.bottom, 5)
|
||||
.padding(.leading, 10)
|
||||
.foregroundColor(.white)
|
||||
|
||||
HStack {
|
||||
Color.jellyfinPurple
|
||||
.frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LandscapePosterProgressBar(
|
||||
title: item.progress ?? L10n.continue,
|
||||
progress: (item.userData?.playedPercentage ?? 0) / 100
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
|
||||
//
|
||||
|
||||
import CollectionView
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ extension ItemView {
|
|||
viewModel.item.type == .episode ? viewModel.item.seriesImageSource(.primary, maxWidth: 300) : viewModel
|
||||
.item.imageSource(.primary, maxWidth: 300)
|
||||
)
|
||||
.portraitPoster(width: 130)
|
||||
.posterStyle(type: .portrait, width: 130)
|
||||
.accessibilityIgnoresInvertColors()
|
||||
|
||||
Button {
|
||||
|
|
|
@ -36,7 +36,7 @@ extension ItemView {
|
|||
)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundStyle(.white)
|
||||
// .foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
@ -54,7 +54,7 @@ extension ItemView {
|
|||
.foregroundStyle(Color.red)
|
||||
} else {
|
||||
Image(systemName: "heart")
|
||||
.foregroundStyle(.white)
|
||||
// .foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
|
|
@ -9,20 +9,26 @@
|
|||
import JellyfinAPI
|
||||
import SwiftUI
|
||||
|
||||
struct EpisodeCard: View {
|
||||
struct EpisodeCard<RowManager: EpisodesRowManager>: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var itemRouter: ItemCoordinator.Router
|
||||
private var router: ItemCoordinator.Router
|
||||
@ScaledMetric
|
||||
private var staticOverviewHeight: CGFloat = 50
|
||||
|
||||
let viewModel: RowManager
|
||||
let episode: BaseItemDto
|
||||
|
||||
var body: some View {
|
||||
PosterButton(item: episode, type: .landscape, singleImage: true)
|
||||
.scaleItem(1.2)
|
||||
.imageOverlay { _ in
|
||||
if episode.userData?.played ?? false {
|
||||
if let progress = episode.progress {
|
||||
LandscapePosterProgressBar(
|
||||
title: progress,
|
||||
progress: (episode.userData?.playedPercentage ?? 0) / 100
|
||||
)
|
||||
} else if episode.userData?.played ?? false {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Color.clear
|
||||
|
||||
|
@ -35,34 +41,53 @@ struct EpisodeCard: View {
|
|||
}
|
||||
}
|
||||
.content { _ in
|
||||
VStack(alignment: .leading) {
|
||||
Text(episode.episodeLocator ?? L10n.unknown)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
Button {
|
||||
router.route(to: \.item, episode)
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(episode.episodeLocator ?? L10n.unknown)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(episode.displayName)
|
||||
.font(.body)
|
||||
.padding(.bottom, 1)
|
||||
.lineLimit(2)
|
||||
Text(episode.displayName)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.bottom, 1)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
Color.clear
|
||||
.frame(height: staticOverviewHeight)
|
||||
ZStack(alignment: .topLeading) {
|
||||
Color.clear
|
||||
.frame(height: staticOverviewHeight)
|
||||
|
||||
if episode.unaired {
|
||||
Text(episode.airDateLabel ?? L10n.noOverviewAvailable)
|
||||
} else {
|
||||
Text(episode.overview ?? L10n.noOverviewAvailable)
|
||||
if episode.unaired {
|
||||
Text(episode.airDateLabel ?? L10n.noOverviewAvailable)
|
||||
} else {
|
||||
Text(episode.overview ?? L10n.noOverviewAvailable)
|
||||
}
|
||||
}
|
||||
.font(.caption.weight(.light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(4)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
L10n.seeMore.text
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.jellyfinPurple)
|
||||
}
|
||||
.font(.caption.weight(.light))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(4)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.onSelect { _ in
|
||||
itemRouter.route(to: \.item, episode)
|
||||
episode.createVideoPlayerViewModel()
|
||||
.sink { completion in
|
||||
self.viewModel.handleAPIRequestError(completion: completion)
|
||||
} receiveValue: { viewModels in
|
||||
if let episodeViewModel = viewModels.first {
|
||||
router.route(to: \.videoPlayer, episodeViewModel)
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,16 +59,16 @@ struct SeriesEpisodesView<RowManager: EpisodesRowManager>: View {
|
|||
HStack(alignment: .top, spacing: 15) {
|
||||
if viewModel.isLoading {
|
||||
ForEach(0 ..< 5) { _ in
|
||||
EpisodeCard(episode: .placeHolder)
|
||||
EpisodeCard(viewModel: viewModel, episode: .placeHolder)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
} else if let selectedSeason = viewModel.selectedSeason {
|
||||
if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] {
|
||||
if seasonEpisodes.isEmpty {
|
||||
EpisodeCard(episode: .noResults)
|
||||
EpisodeCard(viewModel: viewModel, episode: .noResults)
|
||||
} else {
|
||||
ForEach(seasonEpisodes) { episode in
|
||||
EpisodeCard(episode: episode)
|
||||
EpisodeCard(viewModel: viewModel, episode: episode)
|
||||
.id(episode.id)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ extension EpisodeItemView {
|
|||
struct ContentView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var itemRouter: ItemCoordinator.Router
|
||||
private var router: ItemCoordinator.Router
|
||||
@ObservedObject
|
||||
var viewModel: EpisodeItemViewModel
|
||||
|
||||
|
@ -35,7 +35,7 @@ extension EpisodeItemView {
|
|||
|
||||
if let itemOverview = viewModel.item.overview {
|
||||
TruncatedTextView(text: itemOverview) {
|
||||
itemRouter.route(to: \.itemOverview, viewModel.item)
|
||||
router.route(to: \.itemOverview, viewModel.item)
|
||||
}
|
||||
.font(.footnote)
|
||||
.lineLimit(5)
|
||||
|
@ -68,6 +68,15 @@ extension EpisodeItemView {
|
|||
Divider()
|
||||
}
|
||||
|
||||
// MARK: Series
|
||||
|
||||
if let seriesItem = viewModel.seriesItem {
|
||||
PosterHStack(title: L10n.series, type: .portrait, items: [seriesItem])
|
||||
.onSelect { item in
|
||||
router.route(to: \.item, item)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Details
|
||||
|
||||
if let informationItems = viewModel.item.createInformationItems(), !informationItems.isEmpty {
|
||||
|
@ -131,6 +140,7 @@ extension EpisodeItemView.ContentView {
|
|||
ItemView.ActionButtonHStack(viewModel: viewModel)
|
||||
.font(.title)
|
||||
.frame(maxWidth: 300)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ struct EpisodeItemView: View {
|
|||
.navBarOffset(
|
||||
$scrollViewOffset,
|
||||
start: 0,
|
||||
end: 10
|
||||
end: 30
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -152,6 +152,7 @@ extension ItemView.CinematicScrollView {
|
|||
ItemView.ActionButtonHStack(viewModel: viewModel)
|
||||
.font(.title)
|
||||
.frame(maxWidth: 300)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
|
|
|
@ -183,6 +183,7 @@ extension ItemView.CompactLogoScrollView {
|
|||
ItemView.ActionButtonHStack(viewModel: viewModel)
|
||||
.font(.title)
|
||||
.frame(maxWidth: 300)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -179,7 +179,7 @@ extension ItemView.CompactPosterScrollView {
|
|||
// MARK: Portrait Image
|
||||
|
||||
ImageView(viewModel.item.imageSource(.primary, maxWidth: 130))
|
||||
.portraitPoster(width: 130)
|
||||
.posterStyle(type: .portrait, width: 130)
|
||||
.accessibilityIgnoresInvertColors()
|
||||
|
||||
rightShelfView
|
||||
|
@ -197,6 +197,7 @@ extension ItemView.CompactPosterScrollView {
|
|||
|
||||
ItemView.ActionButtonHStack(viewModel: viewModel, equalSpacing: false)
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ extension iPadOSEpisodeItemView {
|
|||
struct ContentView: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var itemRouter: ItemCoordinator.Router
|
||||
private var router: ItemCoordinator.Router
|
||||
@ObservedObject
|
||||
var viewModel: EpisodeItemViewModel
|
||||
|
||||
|
@ -47,6 +47,15 @@ extension iPadOSEpisodeItemView {
|
|||
Divider()
|
||||
}
|
||||
|
||||
// MARK: Series
|
||||
|
||||
if let seriesItem = viewModel.seriesItem {
|
||||
PosterHStack(title: L10n.series, type: .portrait, items: [seriesItem])
|
||||
.onSelect { item in
|
||||
router.route(to: \.item, item)
|
||||
}
|
||||
}
|
||||
|
||||
ItemView.AboutView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -159,6 +159,7 @@ extension ItemView.iPadOSCinematicScrollView {
|
|||
|
||||
ItemView.ActionButtonHStack(viewModel: viewModel)
|
||||
.font(.title)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.frame(width: 250)
|
||||
}
|
||||
|
|
|
@ -21,9 +21,8 @@ struct LibraryItemRow: View {
|
|||
router.route(to: \.item, item)
|
||||
} label: {
|
||||
HStack(alignment: .bottom) {
|
||||
PosterButton(item: item, type: .portrait)
|
||||
.scaleItem(0.6)
|
||||
.content { _ in }
|
||||
ImageView(item.portraitPosterImageSource(maxWidth: 60))
|
||||
.posterStyle(type: .portrait, width: 60)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(item.displayName)
|
||||
|
|
|
@ -31,15 +31,12 @@ struct CustomizeViewsSettings: View {
|
|||
var recommendedPosterType
|
||||
@Default(.Customization.searchPosterType)
|
||||
var searchPosterType
|
||||
@Default(.Customization.Library.gridPosterType)
|
||||
var libraryGridPosterType
|
||||
|
||||
@Default(.Customization.Episodes.useSeriesLandscapeBackdrop)
|
||||
var useSeriesLandscapeBackdrop
|
||||
|
||||
@Default(.Customization.Library.gridPosterType)
|
||||
var libraryGridPosterType
|
||||
@Default(.Customization.Library.viewType)
|
||||
var libraryViewType
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
|
@ -94,6 +91,12 @@ struct CustomizeViewsSettings: View {
|
|||
Text(type.localizedName).tag(type.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
Picker(L10n.library, selection: $libraryGridPosterType) {
|
||||
ForEach(PosterType.allCases, id: \.self) { type in
|
||||
Text(type.localizedName).tag(type.rawValue)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
// TODO: localize after organization
|
||||
Text("Posters")
|
||||
|
@ -106,23 +109,6 @@ struct CustomizeViewsSettings: View {
|
|||
// TODO: localize after organization
|
||||
Text("Episode Landscape Poster")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker(L10n.library, selection: $libraryGridPosterType) {
|
||||
ForEach(PosterType.allCases, id: \.self) { type in
|
||||
Text(type.localizedName).tag(type.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
Picker(L10n.items, selection: $libraryViewType) {
|
||||
ForEach(LibraryViewType.allCases, id: \.self) { type in
|
||||
Text(type.localizedName).tag(type.rawValue)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
// TODO: localize after organization
|
||||
Text("Library")
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.customize)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue