diff --git a/Shared/Generated/Strings.swift b/Shared/Generated/Strings.swift index 7ab73115..f137dd28 100644 --- a/Shared/Generated/Strings.swift +++ b/Shared/Generated/Strings.swift @@ -222,6 +222,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 @@ -250,6 +252,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..8ef6a462 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -184,6 +184,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 diff --git a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift index 5d094b9c..cd14f9ce 100644 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift @@ -24,7 +24,7 @@ final class CollectionItemViewModel: ItemViewModel { private func getCollectionItems() { ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id, parentId: item.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) .trackActivity(loading) .sink { [weak self] completion in self?.handleAPIRequestError(completion: completion) diff --git a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift index 8b3562b4..8f59920f 100644 --- a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift @@ -100,7 +100,7 @@ final class VideoPlayerViewModel: ViewModel { // MARK: General - let item: BaseItemDto + private(set) var item: BaseItemDto let title: String let subtitle: String? let streamURL: URL @@ -235,6 +235,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/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift index 4e6f3772..f0fdba86 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(alignment: .leading) { + 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) { - 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() ?? "") + .font(.caption) + .foregroundColor(.secondary) + Text(episode.name ?? "") + .font(.footnote) + .padding(.bottom, 1) - VStack(alignment: .leading) { - Text(episode.getEpisodeLocator() ?? "") - .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) + .fixedSize(horizontal: false, vertical: true) + } + } - 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) - } - } - .padding(.horizontal) - - Spacer() - } - .frame(width: 500) - } - } - .buttonStyle(PlainButtonStyle()) + Spacer() + } + .padding() + .frame(width: 550) + } + .focusSection() } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift index 512b841b..da737552 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 { @@ -53,8 +55,15 @@ struct CinematicResumeCardView: View { } .frame(width: 350, height: 210) } - .buttonStyle(CardButtonStyle()) - .padding(.top) + .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..ac4527b2 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..86f963e4 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 0c95276d..599bd140 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift @@ -78,6 +78,20 @@ 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") + } + } + } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 8ec7e1f9..b536f07b 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -2797,7 +2797,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2834,7 +2834,7 @@ CURRENT_PROJECT_VERSION = 66; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2865,7 +2865,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2892,7 +2892,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Swiftfin/Views/ContinueWatchingView.swift b/Swiftfin/Views/ContinueWatchingView.swift index 6dd6b04e..3b81ef21 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 62c2c103..89250904 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.itemVideoPlayerViewModel == 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/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index fec028d8..462155d0 100644 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift +++ b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -90,7 +90,11 @@ struct PortraitHeaderOverlayView: View { // MARK: Play Button { - self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + 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") @@ -104,7 +108,22 @@ struct PortraitHeaderOverlayView: View { .frame(width: 130, height: 40) .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) .cornerRadius(10) - }.disabled(viewModel.playButtonItem == 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 4d839fe9..2b050861 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ