tvos chapters
This commit is contained in:
parent
4a58ba2129
commit
aa5bdca917
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import JellyfinAPI
|
||||
|
||||
protocol PlayerOverlayDelegate {
|
||||
|
||||
|
@ -27,4 +28,6 @@ protocol PlayerOverlayDelegate {
|
|||
|
||||
func didSelectPlayPreviousItem()
|
||||
func didSelectPlayNextItem()
|
||||
|
||||
func didSelectChapter(_ chapter: ChapterInfo)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = (
|
||||
|
|
Loading…
Reference in New Issue