diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift index 0797f0e1..010cf779 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift @@ -16,6 +16,7 @@ struct SmallMediaStreamSelectionView: View { case subtitles case audio case playbackSpeed + case chapters } enum MediaSection: Hashable { @@ -25,9 +26,12 @@ struct SmallMediaStreamSelectionView: View { @ObservedObject var viewModel: VideoPlayerViewModel + private let chapterImages: [URL] @State private var updateFocusedLayer: Layer = .subtitles + @State + private var lastFocusedLayer: Layer = .subtitles @FocusState private var subtitlesFocused: Bool @@ -36,6 +40,8 @@ struct SmallMediaStreamSelectionView: View { @FocusState private var playbackSpeedFocused: Bool @FocusState + private var chaptersFocused: Bool + @FocusState private var focusedSection: MediaSection? @FocusState private var focusedLayer: Layer? { @@ -48,8 +54,10 @@ struct SmallMediaStreamSelectionView: View { } } - @State - private var lastFocusedLayer: Layer = .subtitles + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500) + } var body: some View { ZStack(alignment: .bottom) { @@ -161,6 +169,40 @@ struct SmallMediaStreamSelectionView: View { } } + // MARK: Chapters Header + + if !viewModel.chapters.isEmpty { + Button { + updateFocusedLayer = .chapters + focusedLayer = .chapters + } label: { + if updateFocusedLayer == .chapters { + HStack(spacing: 15) { + Image(systemName: "list.dash") + L10n.chapters.text + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "list.dash") + L10n.chapters.text + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .chapters) + .focused($chaptersFocused) + .onChange(of: chaptersFocused) { isFocused in + if isFocused { + focusedLayer = .chapters + } + } + } + Spacer() } .padding() @@ -181,80 +223,144 @@ struct SmallMediaStreamSelectionView: View { if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles { // MARK: Subtitles - ScrollView(.horizontal) { - HStack { - if viewModel.subtitleStreams.isEmpty { - Button {} label: { - L10n.none.text - } - } else { - ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in - Button { - viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 - } label: { - if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { - Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(subtitleStream.displayTitle ?? L10n.noTitle) - } - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } + subtitleMenuView } else if updateFocusedLayer == .audio && lastFocusedLayer == .audio { // MARK: Audio - ScrollView(.horizontal) { - HStack { - if viewModel.audioStreams.isEmpty { - Button {} label: { - Text("None") - } - } else { - ForEach(viewModel.audioStreams, id: \.self) { audioStream in - Button { - viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 - } label: { - if audioStream.index == viewModel.selectedAudioStreamIndex { - Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(audioStream.displayTitle ?? L10n.noTitle) - } - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } + audioMenuView } else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed { - // MARK: Rates + // MARK: Playback Speed - ScrollView(.horizontal) { - HStack { - ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in - Button { - viewModel.playbackSpeed = playbackSpeed - } label: { - if playbackSpeed == viewModel.playbackSpeed { - Label(playbackSpeed.displayTitle, systemImage: "checkmark") - } else { - Text(playbackSpeed.displayTitle) - } - } + playbackSpeedMenuView + } else if updateFocusedLayer == .chapters && lastFocusedLayer == .chapters { + // MARK: Chapters + + chaptersMenuView + } + } + } + } + + @ViewBuilder + private var subtitleMenuView: some View { + ScrollView(.horizontal) { + HStack { + if viewModel.subtitleStreams.isEmpty { + Button {} label: { + L10n.none.text + } + } else { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? L10n.noTitle) } } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) } } } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } + + @ViewBuilder + private var audioMenuView: some View { + ScrollView(.horizontal) { + HStack { + if viewModel.audioStreams.isEmpty { + Button {} label: { + Text("None") + } + } else { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? L10n.noTitle) + } + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } + + @ViewBuilder + private var playbackSpeedMenuView: some View { + ScrollView(.horizontal) { + HStack { + ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in + Button { + viewModel.playbackSpeed = playbackSpeed + } label: { + if playbackSpeed == viewModel.playbackSpeed { + Label(playbackSpeed.displayTitle, systemImage: "checkmark") + } else { + Text(playbackSpeed.displayTitle) + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } + + @ViewBuilder + private var chaptersMenuView: some View { + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { reader in + HStack { + ForEach(0 ..< viewModel.chapters.count) { chapterIndex in + VStack(alignment: .leading) { + Button { + viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex]) + } label: { + ImageView(src: chapterImages[chapterIndex]) + .cornerRadius(10) + .frame(width: 350, height: 210) + } + .buttonStyle(CardButtonStyle()) + + VStack(alignment: .leading, spacing: 5) { + + Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text(viewModel.chapters[chapterIndex].timestampLabel) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(Color(UIColor.systemBlue)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) + } + } + } + .id(viewModel.chapters[chapterIndex]) + } + } + .padding(.top) + .onAppear { + reader.scrollTo(viewModel.currentChapter) + } + } } } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift index 64eac230..12d83836 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift @@ -147,6 +147,7 @@ struct tvOSVLCOverlay_Previews: PreviewProvider { response: PlaybackInfoResponse(), audioStreams: [MediaStream(displayTitle: "English", index: -1)], subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + chapters: [], selectedAudioStreamIndex: -1, selectedSubtitleStreamIndex: -1, subtitlesEnabled: true, diff --git a/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index 4ed7ec35..ac507f02 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -7,6 +7,7 @@ // import Foundation +import JellyfinAPI protocol PlayerOverlayDelegate { @@ -27,4 +28,6 @@ protocol PlayerOverlayDelegate { func didSelectPlayPreviousItem() func didSelectPlayNextItem() + + func didSelectChapter(_ chapter: ChapterInfo) } diff --git a/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 07a82b2a..1ba5b04a 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -881,4 +881,18 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { startPlayback() } } + + func didSelectChapter(_ chapter: ChapterInfo) { + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) + let newPositionOffset = chapterSeconds - videoPosition + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + + viewModel.sendProgressReport() + } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index cd468747..ba3fb503 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -2817,7 +2817,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2854,7 +2854,7 @@ CURRENT_PROJECT_VERSION = 66; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2885,7 +2885,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2912,7 +2912,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = TY84JMYEFE; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = (