iOS advanced seasons episodes selection

This commit is contained in:
Ethan Pippin 2022-01-04 18:47:18 -07:00
parent 1f199dc4f8
commit 78c061d4de
31 changed files with 670 additions and 392 deletions

View File

@ -12,55 +12,8 @@ import SwiftUI
struct ItemDetailsView: View {
@ObservedObject var viewModel: ItemViewModel
private let detailItems: [(String, String)]
private let mediaItems: [(String, String)]
@FocusState private var focused: Bool
init(viewModel: ItemViewModel) {
self.viewModel = viewModel
var initialDetailItems: [(String, String)] = []
if let productionYear = viewModel.item.productionYear {
initialDetailItems.append(("Released", "\(productionYear)"))
}
if let rating = viewModel.item.officialRating {
initialDetailItems.append(("Rated", "\(rating)"))
}
if let runtime = viewModel.item.getItemRuntime() {
initialDetailItems.append(("Runtime", "\(runtime)"))
}
var initialMediatems: [(String, String)] = []
if let container = viewModel.item.container {
let containerList = container.split(separator: ",")
if containerList.count > 1 {
initialMediatems.append(("Containers", containerList.joined(separator: ", ")))
} else {
initialMediatems.append(("Container", containerList.joined(separator: ", ")))
}
}
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
if !itemVideoPlayerViewModel.audioStreams.isEmpty {
let audioList = itemVideoPlayerViewModel.audioStreams.compactMap({ $0.displayTitle }).joined(separator: ", ")
initialMediatems.append(("Audio", audioList))
}
if !itemVideoPlayerViewModel.subtitleStreams.isEmpty {
let subtitlesList = itemVideoPlayerViewModel.subtitleStreams.compactMap({ $0.displayTitle }).joined(separator: ", ")
initialMediatems.append(("Subtitles", subtitlesList))
}
}
detailItems = initialDetailItems
mediaItems = initialMediatems
}
var body: some View {
ZStack(alignment: .leading) {

View File

@ -13,8 +13,9 @@ struct LatestMediaView: View {
@StateObject var tempViewModel = ViewModel()
@State var items: [BaseItemDto] = []
private var library_id: String = ""
@State private var viewDidLoad: Bool = false
private var library_id: String = ""
init(usingParentID: String) {
library_id = usingParentID
@ -26,15 +27,13 @@ struct LatestMediaView: View {
}
viewDidLoad = true
DispatchQueue.global(qos: .userInitiated).async {
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { response in
items = response
})
.store(in: &tempViewModel.cancellables)
}
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { response in
items = response
})
.store(in: &tempViewModel.cancellables)
}
var body: some View {

View File

@ -76,7 +76,6 @@
5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */; };
5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276D263C25100035E14B /* ContinueWatchingView.swift */; };
53892770263C25230035E14B /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389276F263C25230035E14B /* NextUpView.swift */; };
53892772263C8C6F0035E14B /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53892771263C8C6F0035E14B /* LoadingView.swift */; };
5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5389277B263CC3DB0035E14B /* BlurHashDecode.swift */; };
53913BEF26D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; };
53913BF026D323FE00EB3286 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53913BC926D323FE00EB3286 /* Localizable.strings */; };
@ -230,6 +229,13 @@
C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; };
C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; };
E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */; };
E10D87DA2784E4F100BD264C /* ItemViewDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */; };
E10D87DC2784EC5200BD264C /* EpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */; };
E10D87DE278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; };
E10D87DF278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; };
E10D87E0278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; };
E10D87E227852FD000BD264C /* EpisodesRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */; };
E10D87E327852FD000BD264C /* EpisodesRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */; };
E10EAA45277BB646000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA44277BB646000269ED /* JellyfinAPI */; };
E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA46277BB670000269ED /* JellyfinAPI */; };
E10EAA4D277BB716000269ED /* Sliders in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA4C277BB716000269ED /* Sliders */; };
@ -493,7 +499,6 @@
5377CC02263B596B003A4E83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
5389276D263C25100035E14B /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = "<group>"; };
5389276F263C25230035E14B /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = "<group>"; };
53892771263C8C6F0035E14B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
5389277B263CC3DB0035E14B /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
53913BCA26D323FE00EB3286 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = "<group>"; };
53913BCD26D323FE00EB3286 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = Localizable.strings; sourceTree = "<group>"; };
@ -587,6 +592,10 @@
C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = "<group>"; };
C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = "<group>"; };
E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = "<group>"; };
E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewDetailsView.swift; sourceTree = "<group>"; };
E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = "<group>"; };
E10D87DD278510E300BD264C /* PosterSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterSize.swift; sourceTree = "<group>"; };
E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowViewModel.swift; sourceTree = "<group>"; };
E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = "<group>"; };
E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = "<group>"; };
E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = "<group>"; };
@ -767,6 +776,7 @@
E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */,
625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */,
62E632E5267D3F5B0063E547 /* EpisodeItemViewModel.swift */,
E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */,
625CB5722678C32A00530A6E /* HomeViewModel.swift */,
62E632F2267D54030063E547 /* ItemViewModel.swift */,
62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */,
@ -891,6 +901,7 @@
E1AA331E2782639D00F6439C /* OverlayType.swift */,
E193D4DA27193CCA00900D82 /* PillStackable.swift */,
E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */,
E10D87DD278510E300BD264C /* PosterSize.swift */,
E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */,
535870AC2669D8DD00D05A09 /* Typings.swift */,
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */,
@ -1299,7 +1310,6 @@
6213388F265F83A900A81A2A /* LibraryListView.swift */,
53EE24E5265060780068F029 /* LibrarySearchView.swift */,
53DF641D263D9C0600A7CD1A /* LibraryView.swift */,
53892771263C8C6F0035E14B /* LoadingView.swift */,
5389276F263C25230035E14B /* NextUpView.swift */,
E1E5D54A2783E26100692DFE /* SettingsView */,
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */,
@ -1315,7 +1325,9 @@
isa = PBXGroup;
children = (
535BAE9E2649E569005FA86D /* ItemView.swift */,
E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */,
E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */,
E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */,
E18845FB26DEACC400B0C5B7 /* Landscape */,
E18845FA26DEACBE00B0C5B7 /* Portrait */,
);
@ -1955,6 +1967,7 @@
62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */,
C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */,
E193D53727193F8700900D82 /* LibraryListCoordinator.swift in Sources */,
E10D87DF278510E400BD264C /* PosterSize.swift in Sources */,
E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */,
E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */,
E193D54D2719426600900D82 /* LibraryFilterView.swift in Sources */,
@ -2020,6 +2033,7 @@
E1E5D5392783A56B00692DFE /* EpisodesRowView.swift in Sources */,
E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */,
536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */,
E10D87E327852FD000BD264C /* EpisodesRowViewModel.swift in Sources */,
535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */,
E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */,
E193D547271941C500900D82 /* UserListView.swift in Sources */,
@ -2066,6 +2080,7 @@
E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */,
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */,
E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */,
E10D87E227852FD000BD264C /* EpisodesRowViewModel.swift in Sources */,
C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */,
6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */,
6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */,
@ -2100,6 +2115,7 @@
C4BE0763271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */,
53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */,
E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */,
E10D87DA2784E4F100BD264C /* ItemViewDetailsView.swift in Sources */,
E1C812C3277A8E5D00918266 /* VLCPlayerOverlayView.swift in Sources */,
E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */,
091B5A8B2683142E00D78B61 /* UDPBroadCastConnection.swift in Sources */,
@ -2125,6 +2141,7 @@
E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */,
621338B32660A07800A81A2A /* LazyView.swift in Sources */,
6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */,
E10D87DC2784EC5200BD264C /* EpisodesRowView.swift in Sources */,
E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */,
E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */,
62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */,
@ -2172,10 +2189,10 @@
E13DD3EF27178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */,
5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */,
C40CD928271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */,
E10D87DE278510E400BD264C /* PosterSize.swift in Sources */,
E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */,
E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */,
53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */,
53892772263C8C6F0035E14B /* LoadingView.swift in Sources */,
625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -2195,6 +2212,7 @@
E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */,
628B95372670CB800091AF3B /* JellyfinWidget.swift in Sources */,
6264E88E273850380081A12A /* Strings.swift in Sources */,
E10D87E0278510E400BD264C /* PosterSize.swift in Sources */,
E1AD105426D97161003E4A08 /* BaseItemDtoExtensions.swift in Sources */,
E1FCD09A26C4F35A007C8DCF /* ErrorMessage.swift in Sources */,
628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */,

View File

@ -60,7 +60,6 @@ struct EpisodeCardVStackView: View {
.overlay(
Rectangle()
.fill(Color.jellyfinPurple)
.mask(ProgressBar())
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 1.5), height: 7)
.padding(0), alignment: .bottomLeading
)

View File

@ -13,7 +13,6 @@ struct PillHStackView<ItemType: PillStackable>: View {
let title: String
let items: [ItemType]
// let navigationView: (ItemType) -> NavigationView
let selectedAction: (ItemType) -> Void
var body: some View {

View File

@ -12,66 +12,70 @@ import SwiftUI
struct PortraitImageHStackView<TopBarView: View, ItemType: PortraitImageStackable>: View {
let items: [ItemType]
let maxWidth: Int
let maxWidth: CGFloat
let horizontalAlignment: HorizontalAlignment
let textAlignment: TextAlignment
let topBarView: () -> TopBarView
let selectedAction: (ItemType) -> Void
init(items: [ItemType], maxWidth: Int, horizontalAlignment: HorizontalAlignment = .leading, topBarView: @escaping () -> TopBarView, selectedAction: @escaping (ItemType) -> Void) {
init(items: [ItemType],
maxWidth: CGFloat = 110,
horizontalAlignment: HorizontalAlignment = .leading,
textAlignment: TextAlignment = .leading,
topBarView: @escaping () -> TopBarView,
selectedAction: @escaping (ItemType) -> Void) {
self.items = items
self.maxWidth = maxWidth
self.horizontalAlignment = horizontalAlignment
self.textAlignment = textAlignment
self.topBarView = topBarView
self.selectedAction = selectedAction
}
var body: some View {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 0) {
topBarView()
ScrollView(.horizontal, showsIndicators: false) {
VStack {
Spacer().frame(height: 8)
HStack(alignment: .top) {
Spacer().frame(width: 16)
ForEach(items, id: \.title) { item in
Button {
selectedAction(item)
} label: {
VStack {
ImageView(src: item.imageURLContsructor(maxWidth: maxWidth),
bh: item.blurHash,
failureInitials: item.failureInitials)
.frame(width: 100, height: CGFloat(maxWidth))
.cornerRadius(10)
.shadow(radius: 4, y: 2)
HStack(alignment: .top, spacing: 15) {
ForEach(items, id: \.self.portraitImageID) { item in
Button {
selectedAction(item)
} label: {
VStack(alignment: horizontalAlignment) {
ImageView(src: item.imageURLContsructor(maxWidth: Int(maxWidth)),
bh: item.blurHash,
failureInitials: item.failureInitials)
.frame(width: maxWidth, height: maxWidth * 1.5)
.cornerRadius(10)
.shadow(radius: 4, y: 2)
if item.showTitle {
Text(item.title)
.font(.footnote)
.fontWeight(.regular)
.frame(width: 100)
.foregroundColor(.primary)
.multilineTextAlignment(.center)
.multilineTextAlignment(textAlignment)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
}
if let description = item.description {
Text(description)
.font(.caption)
.fontWeight(.medium)
.frame(width: 100)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
}
if let description = item.subtitle {
Text(description)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.secondary)
.multilineTextAlignment(textAlignment)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
}
}
.frame(width: maxWidth)
}
Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
}
}
}.padding(.top, -3)
.padding(.horizontal)
}
}
}
}

View File

@ -23,7 +23,6 @@ struct PortraitItemView: View {
.shadow(radius: 4, y: 2)
.overlay(Rectangle()
.fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255))
.mask(ProgressBar())
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7)
.padding(0), alignment: .bottomLeading)
.overlay(ZStack {

View File

@ -9,71 +9,79 @@
import JellyfinAPI
import SwiftUI
struct ProgressBar: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let tl = CGPoint(x: rect.minX, y: rect.minY)
let tr = CGPoint(x: rect.maxX, y: rect.minY)
let br = CGPoint(x: rect.maxX, y: rect.maxY)
let bls = CGPoint(x: rect.minX + 10, y: rect.maxY)
let blc = CGPoint(x: rect.minX + 10, y: rect.maxY - 10)
path.move(to: tl)
path.addLine(to: tr)
path.addLine(to: br)
path.addLine(to: bls)
path.addRelativeArc(center: blc, radius: 10,
startAngle: Angle.degrees(90), delta: Angle.degrees(90))
return path
}
}
struct ContinueWatchingView: View {
@EnvironmentObject var homeRouter: HomeCoordinator.Router
var items: [BaseItemDto]
@ObservedObject var viewModel: HomeViewModel
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(items, id: \.id) { item in
HStack(alignment: .top, spacing: 20) {
ForEach(viewModel.resumeItems, id: \.id) { item in
Button {
homeRouter.route(to: \.item, item)
} label: {
VStack(alignment: .leading) {
ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash())
.frame(width: 320, height: 180)
.cornerRadius(10)
.shadow(radius: 4, y: 2)
.shadow(radius: 4, y: 2)
.overlay(Rectangle()
.fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255))
.mask(ProgressBar())
.frame(width: CGFloat((item.userData?.playedPercentage ?? 0) * 3.2), height: 7)
.padding(0), alignment: .bottomLeading)
HStack {
ZStack {
ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash())
.frame(width: 320, height: 180)
HStack {
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() ?? "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)
}
}
}
}
}
}
.frame(width: 320, height: 180)
.mask(Rectangle().cornerRadius(10))
.shadow(radius: 4, y: 2)
VStack(alignment: .leading) {
Text("\(item.seriesName ?? item.name ?? "")")
.font(.callout)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
if item.type == "Episode" {
Text("\(item.getEpisodeLocator() ?? "") - \(item.name ?? "")")
if item.itemType == .episode {
Text(item.getEpisodeLocator() ?? "")
.font(.callout)
.fontWeight(.semibold)
.fontWeight(.medium)
.foregroundColor(.secondary)
.lineLimit(1)
.offset(x: -1.4)
}
Spacer()
}.frame(width: 320, alignment: .leading)
}.padding(.top, 10)
.padding(.bottom, 5)
}
}
}
}.padding(.trailing, 16)
}.frame(height: 215)
.padding(EdgeInsets(top: 8, leading: 20, bottom: 10, trailing: 2))
}
}
.padding(.horizontal)
}
}
}

View File

@ -51,35 +51,49 @@ struct HomeView: View {
ScrollView {
VStack(alignment: .leading) {
if !viewModel.resumeItems.isEmpty {
ContinueWatchingView(items: viewModel.resumeItems)
ContinueWatchingView(viewModel: viewModel)
}
if !viewModel.nextUpItems.isEmpty {
NextUpView(items: viewModel.nextUpItems)
}
ForEach(viewModel.libraries, id: \.self) { library in
HStack {
Text(L10n.latestWithString(library.name ?? ""))
PortraitImageHStackView(items: viewModel.nextUpItems,
horizontalAlignment: .leading) {
L10n.nextUp.text
.font(.title2)
.fontWeight(.bold)
Spacer()
Button {
homeRouter
.route(to: \.library, (viewModel: .init(parentID: library.id!,
filters: viewModel.recentFilterSet),
title: library.name ?? ""))
} label: {
HStack {
L10n.seeAll.text.font(.subheadline).fontWeight(.bold)
Image(systemName: "chevron.right").font(Font.subheadline.bold())
.padding()
} selectedAction: { item in
homeRouter.route(to: \.item, item)
}
}
ForEach(viewModel.libraries, id: \.self) { library in
LatestMediaView(viewModel: LatestMediaViewModel(libraryID: library.id!)) {
HStack {
Text(L10n.latestWithString(library.name ?? ""))
.font(.title2)
.fontWeight(.bold)
Spacer()
Button {
homeRouter
.route(to: \.library, (viewModel: .init(parentID: library.id!,
filters: viewModel.recentFilterSet),
title: library.name ?? ""))
} label: {
HStack {
L10n.seeAll.text.font(.subheadline).fontWeight(.bold)
Image(systemName: "chevron.right").font(Font.subheadline.bold())
}
}
}
}.padding(.leading, 16)
.padding(.trailing, 16)
LatestMediaView(viewModel: .init(libraryID: library.id!))
.padding()
}
}
}
.padding(.bottom, UIDevice.current.userInterfaceIdiom == .phone ? 20 : 30)
.padding(.bottom, 50)
}
.introspectScrollView { scrollView in
let control = UIRefreshControl()

View File

@ -0,0 +1,150 @@
//
/*
* 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 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
import JellyfinAPI
struct EpisodesRowView: View {
@EnvironmentObject var itemRouter: ItemCoordinator.Router
@ObservedObject var viewModel: EpisodesRowViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Menu {
ForEach(Array(viewModel.seasonsEpisodes.keys).sorted(by: { $0.name ?? "" < $1.name ?? ""}), id:\.self) { season in
Button {
viewModel.selectedSeason = season
} label: {
if season.id == viewModel.selectedSeason?.id {
Label(season.name ?? "Season", systemImage: "checkmark")
} else {
Text(season.name ?? "Season")
}
}
}
} label: {
HStack(spacing: 5) {
Text(viewModel.selectedSeason?.name ?? "Unknown")
.fontWeight(.semibold)
.fixedSize()
Image(systemName: "chevron.down")
}
}
Spacer()
}
.padding()
ScrollView(.horizontal, showsIndicators: false) {
ScrollViewReader { reader in
HStack(alignment: .top, spacing: 15) {
if viewModel.isLoading {
VStack(alignment: .leading) {
ZStack {
Color.gray.ignoresSafeArea()
ProgressView()
}
.mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10))
.frame(width: 200, height: 112)
VStack(alignment: .leading) {
Text("--")
.font(.footnote)
.foregroundColor(.secondary)
Text("Loading")
.font(.body)
.padding(.bottom, 1)
.lineLimit(2)
}
Spacer()
}
.frame(width: 200)
} else if let selectedSeason = viewModel.selectedSeason {
if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty {
VStack(alignment: .leading) {
Color.gray.ignoresSafeArea()
.mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10))
.frame(width: 200, height: 112)
VStack(alignment: .leading) {
Text("--")
.font(.footnote)
.foregroundColor(.secondary)
Text("No episodes available")
.font(.body)
.padding(.bottom, 1)
.lineLimit(2)
}
Spacer()
}
.frame(width: 200)
} else {
ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id:\.self) { episode in
Button {
itemRouter.route(to: \.item, episode)
} label: {
HStack(alignment: .top) {
VStack(alignment: .leading) {
ImageView(src: episode.getBackdropImage(maxWidth: 200),
bh: episode.getBackdropImageBlurHash())
.mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10))
.frame(width: 200, height: 112)
VStack(alignment: .leading) {
Text(episode.getEpisodeLocator() ?? "")
.font(.footnote)
.foregroundColor(.secondary)
Text(episode.name ?? "")
.font(.body)
.padding(.bottom, 1)
.lineLimit(2)
Text(episode.overview ?? "")
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
.lineLimit(3)
}
Spacer()
}
.frame(width: 200)
}
}
.buttonStyle(PlainButtonStyle())
.id(episode.name)
}
}
}
}
.padding(.horizontal)
.onChange(of: viewModel.selectedSeason) { _ in
if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId {
reader.scrollTo(viewModel.episodeItemViewModel.item.name)
}
}
.onChange(of: viewModel.seasonsEpisodes) { _ in
if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId {
reader.scrollTo(viewModel.episodeItemViewModel.item.name)
}
}
}
.edgesIgnoringSafeArea(.horizontal)
}
}
}
}

View File

@ -19,7 +19,11 @@ struct ItemNavigationView: View {
var body: some View {
ItemView(item: item)
.navigationBarTitle("", displayMode: .inline)
.navigationBarTitle(item.name ?? "", displayMode: .inline)
.introspectNavigationController { navigationController in
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear]
navigationController.navigationBar.titleTextAttributes = textAttributes
}
}
}
@ -60,21 +64,6 @@ private struct ItemView: View {
} label: {
Image(systemName: "ellipsis.circle.fill")
}
case .episode:
Menu {
Button {
(viewModel as? EpisodeItemViewModel)?.routeToSeriesItem()
} label: {
Label("Show Series", systemImage: "text.below.photo")
}
Button {
(viewModel as? EpisodeItemViewModel)?.routeToSeasonItem()
} label: {
Label("Show Season", systemImage: "square.fill.text.grid.1x2")
}
} label: {
Image(systemName: "ellipsis.circle.fill")
}
default:
EmptyView()
}

View File

@ -7,12 +7,15 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Defaults
import JellyfinAPI
import SwiftUI
struct ItemViewBody: View {
@EnvironmentObject var itemRouter: ItemCoordinator.Router
@EnvironmentObject private var viewModel: ItemViewModel
@Default(.showCastAndCrew) var showCastAndCrew
var body: some View {
VStack(alignment: .leading) {
@ -27,13 +30,11 @@ struct ItemViewBody: View {
if let seriesViewModel = viewModel as? SeriesItemViewModel {
PortraitImageHStackView(items: seriesViewModel.seasons,
maxWidth: 150,
topBarView: {
L10n.seasons.text
.font(.callout)
.fontWeight(.semibold)
.padding(.top, 3)
.padding(.leading, 16)
.padding(.bottom)
.padding(.horizontal)
}, selectedAction: { season in
itemRouter.route(to: \.item, season)
})
@ -46,6 +47,7 @@ struct ItemViewBody: View {
selectedAction: { genre in
itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title))
})
.padding(.bottom)
// MARK: Studios
@ -53,42 +55,66 @@ struct ItemViewBody: View {
PillHStackView(title: L10n.studios,
items: studios) { studio in
itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? ""))
}
.padding(.bottom)
}
// MARK: Episodes
if let episodeViewModel = viewModel as? EpisodeItemViewModel {
EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: episodeViewModel))
}
if let episodeViewModel = viewModel as? EpisodeItemViewModel {
if let seriesItem = episodeViewModel.series {
let a = [seriesItem]
PortraitImageHStackView(items: a) {
Text("Series")
.fontWeight(.semibold)
.padding(.bottom)
.padding(.horizontal)
} selectedAction: { seriesItem in
itemRouter.route(to: \.item, seriesItem)
}
}
}
// MARK: Cast & Crew
if let castAndCrew = viewModel.item.people {
PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") },
maxWidth: 150,
topBarView: {
Text("Cast & Crew")
.font(.callout)
.fontWeight(.semibold)
.padding(.top, 3)
.padding(.leading, 16)
},
selectedAction: { person in
itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title))
})
if showCastAndCrew {
if let castAndCrew = viewModel.item.people {
PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") },
topBarView: {
Text("Cast & Crew")
.fontWeight(.semibold)
.padding(.bottom)
.padding(.horizontal)
},
selectedAction: { person in
itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title))
})
}
}
// MARK: More Like This
if !viewModel.similarItems.isEmpty {
PortraitImageHStackView(items: viewModel.similarItems,
maxWidth: 150,
topBarView: {
L10n.moreLikeThis.text
.font(.callout)
.fontWeight(.semibold)
.padding(.top, 3)
.padding(.leading, 16)
.padding(.bottom)
.padding(.horizontal)
},
selectedAction: { item in
itemRouter.route(to: \.item, item)
})
}
// MARK: Details
ItemViewDetailsView(viewModel: viewModel)
.padding()
}
}
}

View File

@ -0,0 +1,58 @@
//
/*
* 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 2021 Aiden Vigue & Jellyfin Contributors
*/
import JellyfinAPI
import SwiftUI
struct ItemViewDetailsView: View {
@ObservedObject var viewModel: ItemViewModel
var body: some View {
VStack(alignment: .leading) {
if !viewModel.informationItems.isEmpty {
VStack(alignment: .leading, spacing: 20) {
Text("Information")
.font(.title3)
.fontWeight(.bold)
ForEach(viewModel.informationItems, id: \.self.title) { informationItem in
VStack(alignment: .leading, spacing: 2) {
Text(informationItem.title)
.font(.subheadline)
Text(informationItem.content)
.font(.subheadline)
.foregroundColor(Color.secondary)
}
}
}
.padding(.bottom, 20)
}
if !viewModel.mediaItems.isEmpty {
VStack(alignment: .leading, spacing: 20) {
Text("Media")
.font(.title3)
.fontWeight(.bold)
ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in
VStack(alignment: .leading, spacing: 2) {
Text(mediaItem.title)
.font(.subheadline)
Text(mediaItem.content)
.font(.subheadline)
.foregroundColor(Color.secondary)
}
}
}
}
}
}
}

View File

@ -8,21 +8,18 @@
import Stinsen
import SwiftUI
struct LatestMediaView: View {
struct LatestMediaView<TopBarView: View>: View {
@EnvironmentObject var homeRouter: HomeCoordinator.Router
@StateObject var viewModel: LatestMediaViewModel
var topBarView: () -> TopBarView
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(viewModel.items, id: \.id) { item in
Button {
homeRouter.route(to: \.item, item)
} label: {
PortraitItemView(item: item)
}
}.padding(.trailing, 16)
}.padding(.leading, 20)
}.frame(height: 200)
PortraitImageHStackView(items: viewModel.items,
horizontalAlignment: .leading) {
topBarView()
} selectedAction: { item in
homeRouter.route(to: \.item, item)
}
}
}

View File

@ -38,27 +38,6 @@ struct LibraryListView: View {
.shadow(radius: 5)
.padding(.bottom, 5)
NavigationLink(destination: LazyView {
L10n.wip.text
}) {
ZStack {
HStack {
Spacer()
L10n.allGenres.text
.foregroundColor(.black)
.font(.subheadline)
.fontWeight(.semibold)
Spacer()
}
}
.padding(16)
.background(Color.white)
.frame(minWidth: 100, maxWidth: .infinity)
}
.cornerRadius(10)
.shadow(radius: 5)
.padding(.bottom, 15)
if !viewModel.isLoading {
ForEach(viewModel.libraries, id: \.id) { library in
if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" {

View File

@ -1,85 +0,0 @@
/* JellyfinPlayer/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 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
struct LoadingView<Content>: View where Content: View {
@Environment(\.colorScheme) var colorScheme
@Binding var isShowing: Bool // should the modal be visible?
var content: () -> Content
var text: String? // the text to display under the ProgressView - defaults to "Loading..."
var body: some View {
GeometryReader { _ in
ZStack(alignment: .center) {
// the content to display - if the modal is showing, we'll blur it
content()
.disabled(isShowing)
.blur(radius: isShowing ? 2 : 0)
// all contents inside here will only be shown when isShowing is true
if isShowing {
// this Rectangle is a semi-transparent black overlay
Rectangle()
.fill(Color.black).opacity(isShowing ? 0.6 : 0)
.edgesIgnoringSafeArea(.all)
// the magic bit - our ProgressView just displays an activity
// indicator, with some text underneath showing what we are doing
HStack {
ProgressView()
Text(text ?? L10n.loading).fontWeight(.semibold).font(.callout).offset(x: 60)
Spacer()
}
.padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 10))
.frame(width: 250)
.background(colorScheme == .dark ? Color(UIColor.systemGray6) : Color.white)
.foregroundColor(Color.primary)
.cornerRadius(16)
}
}
}
}
}
struct LoadingViewNoBlur<Content>: View where Content: View {
@Environment(\.colorScheme) var colorScheme
@Binding var isShowing: Bool // should the modal be visible?
var content: () -> Content
var text: String? // the text to display under the ProgressView - defaults to "Loading..."
var body: some View {
GeometryReader { _ in
ZStack(alignment: .center) {
// the content to display - if the modal is showing, we'll blur it
content()
.disabled(isShowing)
// all contents inside here will only be shown when isShowing is true
if isShowing {
// this Rectangle is a semi-transparent black overlay
Rectangle()
.fill(Color.black).opacity(isShowing ? 0.6 : 0)
.edgesIgnoringSafeArea(.all)
// the magic bit - our ProgressView just displays an activity
// indicator, with some text underneath showing what we are doing
HStack {
ProgressView()
Text(text ?? L10n.loading).fontWeight(.semibold).font(.callout).offset(x: 60)
Spacer()
}
.padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 10))
.frame(width: 250)
.background(colorScheme == .dark ? Color(UIColor.systemGray6) : Color.white)
.foregroundColor(Color.primary)
.cornerRadius(16)
}
}
}
}
}

View File

@ -30,7 +30,7 @@ struct OverlaySettingsView: View {
Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem)
Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem)
Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay)
Toggle("Allow Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu)
Toggle("Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu)
}
}
}

View File

@ -25,6 +25,8 @@ struct SettingsView: View {
@Default(.videoPlayerJumpForward) var jumpForwardLength
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
@Default(.jumpGesturesEnabled) var jumpGesturesEnabled
@Default(.showPosterLabels) var showPosterLabels
@Default(.showCastAndCrew) var showCastAndCrew
var body: some View {
Form {
@ -114,26 +116,9 @@ struct SettingsView: View {
}
Section(header: L10n.accessibility.text) {
// Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles)
// SearchablePicker(label: "Preferred subtitle language",
// options: viewModel.langs,
// optionToString: { $0.name },
// selected: Binding<TrackLanguage>(get: {
// viewModel.langs
// .first(where: { $0.isoCode == autoSelectSubtitlesLangcode
// }) ??
// .auto
// },
// set: { autoSelectSubtitlesLangcode = $0.isoCode }))
// SearchablePicker(label: "Preferred audio language",
// options: viewModel.langs,
// optionToString: { $0.name },
// selected: Binding<TrackLanguage>(get: {
// viewModel.langs
// .first(where: { $0.isoCode == autoSelectAudioLangcode }) ??
// .auto
// },
// set: { autoSelectAudioLangcode = $0.isoCode }))
Toggle("Show Poster Labels", isOn: $showPosterLabels)
Toggle("Show Cast and Crew", isOn: $showCastAndCrew)
Picker(L10n.appearance, selection: $appAppearance) {
ForEach(AppAppearance.allCases, id: \.self) { appearance in
Text(appearance.localizedName).tag(appearance.rawValue)

View File

@ -84,10 +84,9 @@ class VLCPlayerViewController: UIViewController {
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
let defaultNotificationCenter = NotificationCenter.default
defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil)
defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil)
defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.removeObserver(self)
SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didEndPlayback, object: nil)
}
// MARK: viewDidLoad

View File

@ -7,24 +7,36 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Defaults
import Foundation
import JellyfinAPI
// MARK: PortraitImageStackable
extension BaseItemDto: PortraitImageStackable {
public var portraitImageID: String {
return id ?? "no id"
}
public func imageURLContsructor(maxWidth: Int) -> URL {
return self.getPrimaryImage(maxWidth: maxWidth)
switch self.itemType {
case .episode:
return getSeriesPrimaryImage(maxWidth: maxWidth)
default:
return self.getPrimaryImage(maxWidth: maxWidth)
}
}
public var title: String {
return self.name ?? ""
switch self.itemType {
case .episode:
return self.seriesName ?? self.name ?? ""
default:
return self.name ?? ""
}
}
public var description: String? {
public var subtitle: String? {
switch self.itemType {
case .season:
guard let productionYear = productionYear else { return nil }
return "\(productionYear)"
case .episode:
return getEpisodeLocator()
default:
@ -41,4 +53,13 @@ extension BaseItemDto: PortraitImageStackable {
let initials = name.split(separator: " ").compactMap({ String($0).first })
return String(initials)
}
public var showTitle: Bool {
switch self.itemType {
case .episode, .series, .movie:
return Defaults[.showPosterLabels]
default:
return true
}
}
}

View File

@ -84,6 +84,11 @@ extension BaseItemDto {
var subtitle: String? = nil
// MARK: Attach media content to self
var modifiedSelfItem = self
modifiedSelfItem.mediaStreams = mediaSource.mediaStreams
// TODO: other forms of media subtitle
if self.itemType == .episode {
if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() {
@ -101,8 +106,8 @@ extension BaseItemDto {
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode
let videoPlayerViewModel = VideoPlayerViewModel(item: self,
title: self.name ?? "",
let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem,
title: modifiedSelfItem.name ?? "",
subtitle: subtitle,
streamURL: streamURL.url!,
hlsURL: hlsURL.url!,

View File

@ -156,9 +156,9 @@ public extension BaseItemDto {
return text
}
func getItemProgressString() -> String {
func getItemProgressString() -> String? {
if userData?.playbackPositionTicks == nil || userData?.playbackPositionTicks == 0 {
return ""
return nil
}
let remainingSecs = ((runTimeTicks ?? 0) - (userData?.playbackPositionTicks ?? 0)) / 10_000_000
@ -208,4 +208,60 @@ public extension BaseItemDto {
return getPrimaryImage(maxWidth: maxWidth)
}
}
// MARK: ItemDetail
struct ItemDetail {
let title: String
let content: String
}
func createInformationItems() -> [ItemDetail] {
var informationItems: [ItemDetail] = []
if let productionYear = productionYear {
informationItems.append(ItemDetail(title: "Released", content: "\(productionYear)"))
}
if let rating = officialRating {
informationItems.append(ItemDetail(title: "Rated", content: "\(rating)"))
}
if let runtime = getItemRuntime() {
informationItems.append(ItemDetail(title: "Runtime", content: runtime))
}
return informationItems
}
func createMediaItems() -> [ItemDetail] {
var mediaItems: [ItemDetail] = []
if let container = container {
let containerList = container.split(separator: ",").joined(separator: ", ")
if containerList.count > 1 {
mediaItems.append(ItemDetail(title: "Containers", content: containerList))
} else {
mediaItems.append(ItemDetail(title: "Container", content: containerList))
}
}
if let mediaStreams = mediaStreams {
let audioStreams = mediaStreams.filter({ $0.type == .audio })
let subtitleStreams = mediaStreams.filter({ $0.type == .subtitle })
if !audioStreams.isEmpty {
let audioList = audioStreams.compactMap({ $0.displayTitle }).joined(separator: ", ")
mediaItems.append(ItemDetail(title: "Audio", content: audioList))
}
if !subtitleStreams.isEmpty {
let subtitleList = subtitleStreams.compactMap({ "\($0.displayTitle ?? "No Title") (\($0.codec ?? "No Codec"))" }).joined(separator: ", ")
mediaItems.append(ItemDetail(title: "Subtitles", content: subtitleList))
}
}
return mediaItems
}
}

View File

@ -60,6 +60,10 @@ extension BaseItemPerson {
// MARK: PortraitImageStackable
extension BaseItemPerson: PortraitImageStackable {
public var portraitImageID: String {
return (id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials
}
public func imageURLContsructor(maxWidth: Int) -> URL {
return self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth)
}
@ -68,7 +72,7 @@ extension BaseItemPerson: PortraitImageStackable {
return self.name ?? ""
}
public var description: String? {
public var subtitle: String? {
return self.firstRole()
}
@ -81,6 +85,10 @@ extension BaseItemPerson: PortraitImageStackable {
let initials = name.split(separator: " ").compactMap({ String($0).first })
return String(initials)
}
public var showTitle: Bool {
return true
}
}
// MARK: DiplayedType

View File

@ -12,7 +12,9 @@ import Foundation
public protocol PortraitImageStackable {
func imageURLContsructor(maxWidth: Int) -> URL
var title: String { get }
var description: String? { get }
var subtitle: String? { get }
var blurHash: String { get }
var failureInitials: String { get }
var portraitImageID: String { get }
var showTitle: Bool { get }
}

View File

@ -0,0 +1,15 @@
//
/*
* 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 2021 Aiden Vigue & Jellyfin Contributors
*/
import Foundation
enum PosterSize {
case small
case normal
}

View File

@ -21,5 +21,7 @@ enum SwiftfinNotificationCenter {
static let processDeepLink = Notification.Name("processDeepLink")
static let didPurge = Notification.Name("didPurge")
static let didChangeServerCurrentURI = Notification.Name("didChangeCurrentLoginURI")
static let didEndPlayback = Notification.Name("didEndPlayback")
}
}

View File

@ -38,6 +38,11 @@ extension Defaults.Keys {
static let autoSelectSubtitlesLangCode = Key<String>("AutoSelectSubtitlesLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite)
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite)
// Customize settings
static let showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let showCastAndCrew = Key<Bool>("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Video player / overlay settings
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
static let jumpGesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite)

View File

@ -15,12 +15,12 @@ import Stinsen
final class EpisodeItemViewModel: ItemViewModel {
@RouterObject var itemRouter: ItemCoordinator.Router?
var seasonEpisodes: [BaseItemDto] = []
@Published var series: BaseItemDto?
override init(item: BaseItemDto) {
super.init(item: item)
getSeasonEpisodes()
getEpisodeSeries()
}
override func getItemDisplayName() -> String {
@ -32,41 +32,15 @@ final class EpisodeItemViewModel: ItemViewModel {
return false
}
func routeToSeasonItem() {
guard let id = item.seasonId else { return }
UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] item in
self?.itemRouter?.route(to: \.item, item)
})
.store(in: &cancellables)
}
func routeToSeriesItem() {
func getEpisodeSeries() {
guard let id = item.seriesId else { return }
UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] item in
self?.itemRouter?.route(to: \.item, item)
self?.series = item
})
.store(in: &cancellables)
}
private func getSeasonEpisodes() {
guard let seriesID = item.seriesId else { return }
TvShowsAPI.getEpisodes(seriesId: seriesID,
userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
seasonId: item.seasonId ?? "")
.sink { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
} receiveValue: { [weak self] item in
self?.seasonEpisodes = item.items ?? []
}
.store(in: &cancellables)
}
}

View File

@ -0,0 +1,65 @@
//
/*
* 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 2021 Aiden Vigue & Jellyfin Contributors
*/
import JellyfinAPI
import SwiftUI
final class EpisodesRowViewModel: ViewModel {
@ObservedObject var episodeItemViewModel: EpisodeItemViewModel
@Published var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
@Published var selectedSeason: BaseItemDto? {
willSet {
if seasonsEpisodes[newValue!]!.isEmpty {
retrieveEpisodesForSeason(newValue!)
}
}
}
init(episodeItemViewModel: EpisodeItemViewModel) {
self.episodeItemViewModel = episodeItemViewModel
super.init()
retrieveSeasons()
}
private func retrieveSeasons() {
TvShowsAPI.getSeasons(seriesId: episodeItemViewModel.item.seriesId ?? "",
userId: SessionManager.main.currentLogin.user.id)
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { response in
let seasons = response.items ?? []
seasons.forEach { season in
self.seasonsEpisodes[season] = []
if season.id == self.episodeItemViewModel.item.seasonId ?? "" {
self.selectedSeason = season
}
}
}
.store(in: &cancellables)
}
private func retrieveEpisodesForSeason(_ season: BaseItemDto) {
guard let seasonID = season.id else { return }
TvShowsAPI.getEpisodes(seriesId: episodeItemViewModel.item.seriesId ?? "",
userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
seasonId: seasonID)
.trackActivity(loading)
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { episodes in
self.seasonsEpisodes[season] = episodes.items ?? []
}
.store(in: &cancellables)
}
}

View File

@ -32,9 +32,14 @@ final class HomeViewModel: ViewModel {
let nc = SwiftfinNotificationCenter.main
nc.addObserver(self, selector: #selector(didSignIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil)
nc.addObserver(self, selector: #selector(didSignOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil)
nc.addObserver(self, selector: #selector(didEndPlayback), name: SwiftfinNotificationCenter.Keys.didEndPlayback, object: nil)
}
deinit {
SwiftfinNotificationCenter.main.removeObserver(self)
}
@objc func didSignIn() {
@objc private func didSignIn() {
for cancellable in cancellables {
cancellable.cancel()
}
@ -47,16 +52,29 @@ final class HomeViewModel: ViewModel {
refresh()
}
@objc func didSignOut() {
@objc private func didSignOut() {
for cancellable in cancellables {
cancellable.cancel()
}
cancellables.removeAll()
}
@objc private func didEndPlayback() {
refreshResumeItems()
refreshNextUpItems()
}
func refresh() {
@objc func refresh() {
LogManager.shared.log.debug("Refresh called.")
refreshLibrariesLatest()
refreshResumeItems()
refreshNextUpItems()
}
// MARK: Libraries Latest Items
private func refreshLibrariesLatest() {
UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id)
.trackActivity(loading)
.sink(receiveCompletion: { completion in
@ -101,7 +119,10 @@ final class HomeViewModel: ViewModel {
.store(in: &self.cancellables)
})
.store(in: &cancellables)
}
// MARK: Resume Items
private func refreshResumeItems() {
ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id, limit: 12,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters],
mediaTypes: ["Video"],
@ -121,7 +142,10 @@ final class HomeViewModel: ViewModel {
self.resumeItems = response.items ?? []
})
.store(in: &cancellables)
}
// MARK: Next Up Items
private func refreshNextUpItems() {
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, limit: 12,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters])
.trackActivity(loading)

View File

@ -19,6 +19,8 @@ class ItemViewModel: ViewModel {
@Published var similarItems: [BaseItemDto] = []
@Published var isWatched = false
@Published var isFavorited = false
@Published var informationItems: [BaseItemDto.ItemDetail]
@Published var mediaItems: [BaseItemDto.ItemDetail]
var itemVideoPlayerViewModel: VideoPlayerViewModel?
init(item: BaseItemDto) {
@ -29,6 +31,9 @@ class ItemViewModel: ViewModel {
self.playButtonItem = item
default: ()
}
informationItems = item.createInformationItems()
mediaItems = item.createMediaItems()
isFavorited = item.userData?.isFavorite ?? false
isWatched = item.userData?.played ?? false
@ -41,12 +46,17 @@ class ItemViewModel: ViewModel {
self.handleAPIRequestError(completion: completion)
} receiveValue: { videoPlayerViewModel in
self.itemVideoPlayerViewModel = videoPlayerViewModel
self.mediaItems = videoPlayerViewModel.item.createMediaItems()
}
.store(in: &cancellables)
}
func playButtonText() -> String {
return item.getItemProgressString() == "" ? L10n.play : item.getItemProgressString()
if let itemProgressString = item.getItemProgressString() {
return itemProgressString
}
return L10n.play
}
func getItemDisplayName() -> String {