tvos chapters
This commit is contained in:
parent
4a58ba2129
commit
aa5bdca917
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = (
|
||||||
|
|
Loading…
Reference in New Issue