diff --git a/Shared/Generated/Strings.swift b/Shared/Generated/Strings.swift index 3f54c7cc..3842116f 100644 --- a/Shared/Generated/Strings.swift +++ b/Shared/Generated/Strings.swift @@ -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 diff --git a/Shared/ViewModels/EpisodesRowManager.swift b/Shared/ViewModels/EpisodesRowManager.swift index a31d989b..5bc12889 100644 --- a/Shared/ViewModels/EpisodesRowManager.swift +++ b/Shared/ViewModels/EpisodesRowManager.swift @@ -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() - } -} diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 0de66a2f..1ed0c40b 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -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() { diff --git a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift index 112c6b0a..25b48078 100644 --- a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift @@ -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 { diff --git a/Shared/Views/ImageView.swift b/Shared/Views/ImageView.swift index 8fb14bdf..911e932b 100644 --- a/Shared/Views/ImageView.swift +++ b/Shared/Views/ImageView.swift @@ -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)) } } diff --git a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift index 4e6f3772..0f27d13d 100644 --- a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift +++ b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift @@ -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() } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift index 512b841b..8586b5b4 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift @@ -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) } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift b/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift index b616f260..902220b0 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift @@ -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) diff --git a/Swiftfin tvOS/Views/HomeView.swift b/Swiftfin tvOS/Views/HomeView.swift index b4ca465d..1d341e2c 100644 --- a/Swiftfin tvOS/Views/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView.swift @@ -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) diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift index 49314d96..5b52a807 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift @@ -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 + } + } + } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 8ec7e1f9..e91d1ec5 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -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" */; diff --git a/Swiftfin.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3ed0ea18..00182b81 100644 --- a/Swiftfin.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" } }, { diff --git a/Swiftfin/Views/ContinueWatchingView.swift b/Swiftfin/Views/ContinueWatchingView.swift index 6dd6b04e..8c5c04c2 100644 --- a/Swiftfin/Views/ContinueWatchingView.swift +++ b/Swiftfin/Views/ContinueWatchingView.swift @@ -81,6 +81,13 @@ struct ContinueWatchingView: View { } } } + .contextMenu { + Button(role: .destructive) { + viewModel.removeItemFromResume(item) + } label: { + L10n.removeFromResume.text + } + } } } .padding(.horizontal) diff --git a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift index fc745a47..e2af12b6 100644 --- a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift @@ -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() } diff --git a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index 2c54cb82..18d8040e 100644 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift +++ b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -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() diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 4fa3a681..2b050861 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ