Merge branch 'main' into multiple-media-sources

This commit is contained in:
Ethan Pippin 2022-01-12 21:56:12 -07:00
commit cdc294dd96
16 changed files with 206 additions and 75 deletions

View File

@ -224,6 +224,8 @@ internal enum L10n {
internal static let playbackSettings = L10n.tr("Localizable", "playbackSettings")
/// Playback Speed
internal static let playbackSpeed = L10n.tr("Localizable", "playbackSpeed")
/// Play From Beginning
internal static let playFromBeginning = L10n.tr("Localizable", "playFromBeginning")
/// Play Next
internal static let playNext = L10n.tr("Localizable", "playNext")
/// Play Next Item
@ -252,6 +254,8 @@ internal enum L10n {
internal static let remove = L10n.tr("Localizable", "remove")
/// Remove All Users
internal static let removeAllUsers = L10n.tr("Localizable", "removeAllUsers")
/// Remove From Resume
internal static let removeFromResume = L10n.tr("Localizable", "removeFromResume")
/// Reset
internal static let reset = L10n.tr("Localizable", "reset")
/// Reset App Settings

View File

@ -70,19 +70,3 @@ extension EpisodesRowManager {
}
}
}
final class SingleSeasonEpisodesRowViewModel: ViewModel {
// TODO: Protocol these viewmodels for generalization instead of Season
@ObservedObject
var seasonItemViewModel: SeasonItemViewModel
@Published
var episodes: [BaseItemDto]
init(seasonItemViewModel: SeasonItemViewModel) {
self.seasonItemViewModel = seasonItemViewModel
self.episodes = seasonItemViewModel.episodes
super.init()
}
}

View File

@ -185,6 +185,20 @@ final class HomeViewModel: ViewModel {
.store(in: &cancellables)
}
func removeItemFromResume(_ item: BaseItemDto) {
guard let itemID = item.id, resumeItems.contains(where: { $0.id == itemID }) else { return }
PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id,
itemId: item.id!)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { _ in
self.refreshResumeItems()
self.refreshNextUpItems()
})
.store(in: &cancellables)
}
// MARK: Next Up Items
private func refreshNextUpItems() {

View File

@ -103,7 +103,7 @@ final class VideoPlayerViewModel: ViewModel {
// MARK: General
let item: BaseItemDto
private(set) var item: BaseItemDto
let title: String
let subtitle: String?
let streamURL: URL
@ -249,6 +249,22 @@ final class VideoPlayerViewModel: ViewModel {
}
}
// MARK: Injected Values
extension VideoPlayerViewModel {
// Injects custom values that override certain settings
func injectCustomValues(startFromBeginning: Bool = false) {
if startFromBeginning {
item.userData?.playbackPositionTicks = 0
item.userData?.playedPercentage = 0
sliderPercentage = 0
sliderPercentageChanged(newValue: 0)
}
}
}
// MARK: Adjacent Items
extension VideoPlayerViewModel {

View File

@ -6,6 +6,7 @@
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import NukeUI
import SwiftUI
struct ImageView: View {
@ -40,17 +41,12 @@ struct ImageView: View {
}
var body: some View {
AsyncImage(url: source, transaction: Transaction(animation: .easeInOut)) { phase in
switch phase {
case let .success(image):
LazyImage(source: source) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
} else if state.error != nil {
failureImage
default:
// TODO: remove once placeholder hash image fixed
} else {
#if os(tvOS)
ZStack {
Color.black.ignoresSafeArea()
@ -66,5 +62,6 @@ struct ImageView: View {
#endif
}
}
.pipeline(ImagePipeline(configuration: .withDataCache))
}
}

View File

@ -17,45 +17,47 @@ struct EpisodeRowCard: View {
let episode: BaseItemDto
var body: some View {
Button {
itemRouter.route(to: \.item, episode)
} label: {
HStack(alignment: .top) {
VStack {
Button {
itemRouter.route(to: \.item, episode)
} label: {
ImageView(src: episode.getBackdropImage(maxWidth: 550),
bh: episode.getBackdropImageBlurHash())
.mask(Rectangle().frame(width: 550, height: 308))
.frame(width: 550, height: 308)
}
.buttonStyle(CardButtonStyle())
VStack(alignment: .leading) {
VStack(alignment: .leading) {
Text(episode.getEpisodeLocator() ?? "")
.font(.caption)
.foregroundColor(.secondary)
Text(episode.name ?? "")
.font(.footnote)
.padding(.bottom, 1)
ImageView(src: episode.getBackdropImage(maxWidth: 500),
bh: episode.getBackdropImageBlurHash())
.mask(Rectangle().frame(width: 500, height: 280))
.frame(width: 500, height: 280)
VStack(alignment: .leading) {
Text(episode.getEpisodeLocator() ?? "")
if episode.unaired {
Text(episode.airDateLabel ?? L10n.noOverviewAvailable)
.font(.caption)
.foregroundColor(.secondary)
Text(episode.name ?? "")
.font(.footnote)
.padding(.bottom, 1)
if episode.unaired {
Text(episode.airDateLabel ?? L10n.noOverviewAvailable)
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
.lineLimit(3)
} else {
Text(episode.overview ?? "")
.font(.caption)
.fontWeight(.light)
.lineLimit(4)
}
.fontWeight(.light)
.lineLimit(3)
} else {
Text(episode.overview ?? "")
.font(.caption)
.fontWeight(.light)
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal)
Spacer()
}
.frame(width: 500)
Spacer()
}
.padding()
.frame(width: 550)
}
.buttonStyle(PlainButtonStyle())
.focusSection()
}
}

View File

@ -13,6 +13,8 @@ struct CinematicResumeCardView: View {
@EnvironmentObject
var homeRouter: HomeCoordinator.Router
@ObservedObject
var viewModel: HomeViewModel
let item: BaseItemDto
var body: some View {
@ -55,6 +57,13 @@ struct CinematicResumeCardView: View {
}
.buttonStyle(CardButtonStyle())
.padding(.top)
.contextMenu {
Button(role: .destructive) {
viewModel.removeItemFromResume(item)
} label: {
L10n.removeFromResume.text
}
}
}
.padding(.vertical)
}

View File

@ -33,6 +33,8 @@ struct HomeCinematicView: View {
@FocusState
var selectedItem: BaseItemDto?
@ObservedObject
var viewModel: HomeViewModel
@State
private var updatedSelectedItem: BaseItemDto?
@State
@ -41,7 +43,8 @@ struct HomeCinematicView: View {
private let items: [HomeCinematicViewItem]
private let backgroundViewModel = DynamicCinematicBackgroundViewModel()
init(items: [HomeCinematicViewItem], forcedItemSubtitle: String? = nil) {
init(viewModel: HomeViewModel, items: [HomeCinematicViewItem], forcedItemSubtitle: String? = nil) {
self.viewModel = viewModel
self.items = items
self.forcedItemSubtitle = forcedItemSubtitle
}
@ -99,7 +102,7 @@ struct HomeCinematicView: View {
CinematicNextUpCardView(item: item.item, showOverlay: true)
.focused($selectedItem, equals: item.item)
case .resume:
CinematicResumeCardView(item: item.item)
CinematicResumeCardView(viewModel: viewModel, item: item.item)
.focused($selectedItem, equals: item.item)
case .plain:
CinematicNextUpCardView(item: item.item, showOverlay: false)

View File

@ -15,7 +15,7 @@ struct HomeView: View {
@EnvironmentObject
var homeRouter: HomeCoordinator.Router
@ObservedObject
@StateObject
var viewModel = HomeViewModel()
@Default(.showPosterLabels)
var showPosterLabels
@ -32,7 +32,8 @@ struct HomeView: View {
LazyVStack(alignment: .leading) {
if viewModel.resumeItems.isEmpty {
HomeCinematicView(items: viewModel.latestAddedItems.map { .init(item: $0, type: .plain) },
HomeCinematicView(viewModel: viewModel,
items: viewModel.latestAddedItems.map { .init(item: $0, type: .plain) },
forcedItemSubtitle: L10n.recentlyAdded)
if !viewModel.nextUpItems.isEmpty {
@ -40,7 +41,8 @@ struct HomeView: View {
.focusSection()
}
} else {
HomeCinematicView(items: viewModel.resumeItems.map { .init(item: $0, type: .resume) })
HomeCinematicView(viewModel: viewModel,
items: viewModel.resumeItems.map { .init(item: $0, type: .resume) })
if !viewModel.nextUpItems.isEmpty {
NextUpView(items: viewModel.nextUpItems)

View File

@ -78,6 +78,24 @@ struct CinematicItemViewTopRow: View {
.cornerRadius(10)
}
.buttonStyle(CardButtonStyle())
.contextMenu {
if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 {
Button {
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
itemVideoPlayerViewModel.injectCustomValues(startFromBeginning: true)
itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel)
} else {
LogManager.shared.log.error("Attempted to play item but no playback information available")
}
} label: {
Label(L10n.playFromBeginning, systemImage: "gobackward")
}
Button(role: .cancel) {} label: {
L10n.cancel.text
}
}
}
}
}

View File

@ -281,6 +281,7 @@
E11B1B6E2718CDBA006DA3E8 /* JellyfinAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */; };
E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */; };
E11D224327378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11D224127378428003F9CB3 /* ServerDetailCoordinator.swift */; };
E11D83AF278FA998006E9776 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E11D83AE278FA998006E9776 /* NukeUI */; };
E12186DE2718F1C50010884C /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E12186DD2718F1C50010884C /* Defaults */; };
E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = E1218C9D271A2CD600EA0737 /* CombineExt */; };
E122A9132788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E122A9122788EAAD0060FA63 /* MediaStreamExtension.swift */; };
@ -291,6 +292,7 @@
E131691726C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; };
E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; };
E131691926C583BC0074BFEE /* LogConstructor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E131691626C583BC0074BFEE /* LogConstructor.swift */; };
E1361DA7278FA7A300BEC523 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1361DA6278FA7A300BEC523 /* NukeUI */; };
E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1384943278036C70024FB48 /* VLCPlayerViewController.swift */; };
E13849452780370B0024FB48 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; };
E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3BE27163DD7009D4DAF /* AppDelegate.swift */; };
@ -779,6 +781,7 @@
buildActionMask = 2147483647;
files = (
53649AAF269CFAF600A2D8B7 /* Puppy in Frameworks */,
E11D83AF278FA998006E9776 /* NukeUI in Frameworks */,
E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */,
6220D0C926D63F3700B8E046 /* Stinsen in Frameworks */,
535870912669D7A800D05A09 /* Introspect in Frameworks */,
@ -799,6 +802,7 @@
buildActionMask = 2147483647;
files = (
E13DD3D327168E65009D4DAF /* Defaults in Frameworks */,
E1361DA7278FA7A300BEC523 /* NukeUI in Frameworks */,
53649AAD269CFAEA00A2D8B7 /* Puppy in Frameworks */,
E10EAA4D277BB716000269ED /* Sliders in Frameworks */,
62C29E9C26D0FE4200C1D2E7 /* Stinsen in Frameworks */,
@ -1714,6 +1718,7 @@
E1A9999A271A343C008E78C0 /* SwiftUICollection */,
E178857C278037FD0094FBCF /* JellyfinAPI */,
E1AE8E7D2789136D00FBDDAA /* Nuke */,
E11D83AE278FA998006E9776 /* NukeUI */,
);
productName = "JellyfinPlayer tvOS";
productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */;
@ -1752,6 +1757,7 @@
E10EAA44277BB646000269ED /* JellyfinAPI */,
E10EAA4C277BB716000269ED /* Sliders */,
E1AE8E7B2789135A00FBDDAA /* Nuke */,
E1361DA6278FA7A300BEC523 /* NukeUI */,
);
productName = JellyfinPlayer;
productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */;
@ -1844,6 +1850,7 @@
E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */,
E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */,
E1AE8E7A2789135A00FBDDAA /* XCRemoteSwiftPackageReference "Nuke" */,
E1361DA5278FA7A300BEC523 /* XCRemoteSwiftPackageReference "NukeUI" */,
);
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
projectDirPath = "";
@ -3024,6 +3031,14 @@
minimumVersion = 1.0.0;
};
};
E1361DA5278FA7A300BEC523 /* XCRemoteSwiftPackageReference "NukeUI" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/NukeUI";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.7.0;
};
};
E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/JohnEstropia/CoreStore.git";
@ -3044,8 +3059,8 @@
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/Nuke";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 9.6.0;
kind = upToNextMajorVersion;
minimumVersion = 10.0.0;
};
};
E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */ = {
@ -3129,6 +3144,11 @@
package = E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */;
productName = Sliders;
};
E11D83AE278FA998006E9776 /* NukeUI */ = {
isa = XCSwiftPackageProductDependency;
package = E1361DA5278FA7A300BEC523 /* XCRemoteSwiftPackageReference "NukeUI" */;
productName = NukeUI;
};
E12186DD2718F1C50010884C /* Defaults */ = {
isa = XCSwiftPackageProductDependency;
package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */;
@ -3139,6 +3159,11 @@
package = E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */;
productName = CombineExt;
};
E1361DA6278FA7A300BEC523 /* NukeUI */ = {
isa = XCSwiftPackageProductDependency;
package = E1361DA5278FA7A300BEC523 /* XCRemoteSwiftPackageReference "NukeUI" */;
productName = NukeUI;
};
E13DD3C52716499E009D4DAF /* CoreStore */ = {
isa = XCSwiftPackageProductDependency;
package = E13DD3C42716499E009D4DAF /* XCRemoteSwiftPackageReference "CoreStore" */;

View File

@ -24,8 +24,8 @@
"repositoryURL": "https://github.com/CombineCommunity/CombineExt",
"state": {
"branch": null,
"revision": "0880829102152185190064fd17847a7c681d2127",
"version": "1.5.1"
"revision": "8ca006df5e3cc6bb176b70238e2b0014bbc3a235",
"version": "1.0.0"
}
},
{
@ -42,8 +42,17 @@
"repositoryURL": "https://github.com/sindresorhus/Defaults",
"state": {
"branch": null,
"revision": "55f3302c3ab30a8760f10042d0ebc0a6907f865a",
"version": "6.1.0"
"revision": "8a6e4a96fd38504a05903d136c85634b65fd7c4d",
"version": "6.0.0"
}
},
{
"package": "Gifu",
"repositoryURL": "https://github.com/kaishin/Gifu",
"state": {
"branch": null,
"revision": "51f2eab32903e336f590c013267cfa4d7f8b06c4",
"version": "3.3.1"
}
},
{
@ -60,8 +69,17 @@
"repositoryURL": "https://github.com/kean/Nuke",
"state": {
"branch": null,
"revision": "7f73ceaeacd5df75a7994cd82e165ad9ff1815db",
"version": "9.6.1"
"revision": "6be3e778f1663b16dd645b7e8a0a01f73b5ed7f3",
"version": "10.6.1"
}
},
{
"package": "NukeUI",
"repositoryURL": "https://github.com/kean/NukeUI",
"state": {
"branch": null,
"revision": "08e953d8d80b409bebcd95ba0635fdd748934ce0",
"version": "0.7.0"
}
},
{
@ -78,8 +96,8 @@
"repositoryURL": "https://github.com/sushichop/Puppy",
"state": {
"branch": null,
"revision": "95ce04b0e778b8d7c351876bc98bbf68328dfc9b",
"version": "0.3.1"
"revision": "dc82e65c749cee431ffbb8c0913680b61ccd7e08",
"version": "0.2.0"
}
},
{

View File

@ -81,6 +81,13 @@ struct ContinueWatchingView: View {
}
}
}
.contextMenu {
Button(role: .destructive) {
viewModel.removeItemFromResume(item)
} label: {
L10n.removeFromResume.text
}
}
}
}
.padding(.horizontal)

View File

@ -50,6 +50,20 @@ struct ItemLandscapeMainView: View {
.cornerRadius(10)
}
.disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil)
.contextMenu {
if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 {
Button {
if let itemVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel {
itemVideoPlayerViewModel.injectCustomValues(startFromBeginning: true)
itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel)
} else {
LogManager.shared.log.error("Attempted to play item but no playback information available")
}
} label: {
Label(L10n.playFromBeginning, systemImage: "gobackward")
}
}
}
Spacer()
}

View File

@ -113,7 +113,11 @@ struct PortraitHeaderOverlayView: View {
// MARK: Play
Button {
self.itemRouter.route(to: \.videoPlayer, viewModel.selectedVideoPlayerViewModel!)
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel)
} else {
LogManager.shared.log.error("Attempted to play item but no playback information available")
}
} label: {
HStack {
Image(systemName: "play.fill")
@ -128,7 +132,21 @@ struct PortraitHeaderOverlayView: View {
.background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple)
.cornerRadius(10)
}
.disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil)
.disabled(viewModel.playButtonItem == nil)
.contextMenu {
if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 {
Button {
if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel {
itemVideoPlayerViewModel.injectCustomValues(startFromBeginning: true)
itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel)
} else {
LogManager.shared.log.error("Attempted to play item but no playback information available")
}
} label: {
Label(L10n.playFromBeginning, systemImage: "gobackward")
}
}
}
Spacer()