tvos chapters

This commit is contained in:
Ethan Pippin 2022-01-15 22:50:20 -07:00
parent 4a58ba2129
commit aa5bdca917
5 changed files with 194 additions and 70 deletions

View File

@ -16,6 +16,7 @@ struct SmallMediaStreamSelectionView: View {
case subtitles case subtitles
case audio case audio
case playbackSpeed case playbackSpeed
case chapters
} }
enum MediaSection: Hashable { enum MediaSection: Hashable {
@ -25,9 +26,12 @@ struct SmallMediaStreamSelectionView: View {
@ObservedObject @ObservedObject
var viewModel: VideoPlayerViewModel var viewModel: VideoPlayerViewModel
private let chapterImages: [URL]
@State @State
private var updateFocusedLayer: Layer = .subtitles private var updateFocusedLayer: Layer = .subtitles
@State
private var lastFocusedLayer: Layer = .subtitles
@FocusState @FocusState
private var subtitlesFocused: Bool private var subtitlesFocused: Bool
@ -36,6 +40,8 @@ struct SmallMediaStreamSelectionView: View {
@FocusState @FocusState
private var playbackSpeedFocused: Bool private var playbackSpeedFocused: Bool
@FocusState @FocusState
private var chaptersFocused: Bool
@FocusState
private var focusedSection: MediaSection? private var focusedSection: MediaSection?
@FocusState @FocusState
private var focusedLayer: Layer? { private var focusedLayer: Layer? {
@ -48,8 +54,10 @@ struct SmallMediaStreamSelectionView: View {
} }
} }
@State init(viewModel: VideoPlayerViewModel) {
private var lastFocusedLayer: Layer = .subtitles self.viewModel = viewModel
self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500)
}
var body: some View { var body: some View {
ZStack(alignment: .bottom) { 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() Spacer()
} }
.padding() .padding()
@ -181,80 +223,144 @@ struct SmallMediaStreamSelectionView: View {
if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles { if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles {
// MARK: Subtitles // MARK: Subtitles
ScrollView(.horizontal) { subtitleMenuView
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)
}
} else if updateFocusedLayer == .audio && lastFocusedLayer == .audio { } else if updateFocusedLayer == .audio && lastFocusedLayer == .audio {
// MARK: Audio // MARK: Audio
ScrollView(.horizontal) { audioMenuView
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)
}
} else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed { } else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed {
// MARK: Rates // MARK: Playback Speed
ScrollView(.horizontal) { playbackSpeedMenuView
HStack { } else if updateFocusedLayer == .chapters && lastFocusedLayer == .chapters {
ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in // MARK: Chapters
Button {
viewModel.playbackSpeed = playbackSpeed chaptersMenuView
} label: { }
if playbackSpeed == viewModel.playbackSpeed { }
Label(playbackSpeed.displayTitle, systemImage: "checkmark") }
} else { }
Text(playbackSpeed.displayTitle)
} @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)
}
}
} }
} }
} }

View File

@ -147,6 +147,7 @@ struct tvOSVLCOverlay_Previews: PreviewProvider {
response: PlaybackInfoResponse(), response: PlaybackInfoResponse(),
audioStreams: [MediaStream(displayTitle: "English", index: -1)], audioStreams: [MediaStream(displayTitle: "English", index: -1)],
subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], subtitleStreams: [MediaStream(displayTitle: "None", index: -1)],
chapters: [],
selectedAudioStreamIndex: -1, selectedAudioStreamIndex: -1,
selectedSubtitleStreamIndex: -1, selectedSubtitleStreamIndex: -1,
subtitlesEnabled: true, subtitlesEnabled: true,

View File

@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import JellyfinAPI
protocol PlayerOverlayDelegate { protocol PlayerOverlayDelegate {
@ -27,4 +28,6 @@ protocol PlayerOverlayDelegate {
func didSelectPlayPreviousItem() func didSelectPlayPreviousItem()
func didSelectPlayNextItem() func didSelectPlayNextItem()
func didSelectChapter(_ chapter: ChapterInfo)
} }

View File

@ -881,4 +881,18 @@ extension VLCPlayerViewController: PlayerOverlayDelegate {
startPlayback() 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()
}
} }

View File

@ -2817,7 +2817,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66; CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = TY84JMYEFE; DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
EXCLUDED_ARCHS = ""; EXCLUDED_ARCHS = "";
@ -2854,7 +2854,7 @@
CURRENT_PROJECT_VERSION = 66; CURRENT_PROJECT_VERSION = 66;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = TY84JMYEFE; DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
EXCLUDED_ARCHS = ""; EXCLUDED_ARCHS = "";
@ -2885,7 +2885,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66; CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_TEAM = TY84JMYEFE; DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = WidgetExtension/Info.plist; INFOPLIST_FILE = WidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -2912,7 +2912,7 @@
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 66; CURRENT_PROJECT_VERSION = 66;
DEVELOPMENT_TEAM = TY84JMYEFE; DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = WidgetExtension/Info.plist; INFOPLIST_FILE = WidgetExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (