Add audio, subtitle, playback select for tvOS (#859)
This commit is contained in:
parent
667d48b0e9
commit
289868e71a
|
@ -40,6 +40,8 @@ class VideoPlayerManager: ViewModel {
|
||||||
var state: VLCVideoPlayer.State = .opening
|
var state: VLCVideoPlayer.State = .opening
|
||||||
@Published
|
@Published
|
||||||
var subtitleTrackIndex: Int = -1
|
var subtitleTrackIndex: Int = -1
|
||||||
|
@Published
|
||||||
|
var playbackSpeed: PlaybackSpeed = PlaybackSpeed.one
|
||||||
|
|
||||||
// MARK: ViewModel
|
// MARK: ViewModel
|
||||||
|
|
||||||
|
|
|
@ -16,16 +16,7 @@ struct SFSymbolButton: UIViewRepresentable {
|
||||||
private let systemName: String
|
private let systemName: String
|
||||||
private let systemNameFocused: String?
|
private let systemNameFocused: String?
|
||||||
|
|
||||||
func makeUIView(context: Context) -> some UIButton {
|
private func makeButtonConfig(_ button: UIButton) {
|
||||||
var configuration = UIButton.Configuration.plain()
|
|
||||||
configuration.cornerStyle = .capsule
|
|
||||||
|
|
||||||
let buttonAction = UIAction(title: "") { _ in
|
|
||||||
self.onSelect()
|
|
||||||
}
|
|
||||||
|
|
||||||
let button = UIButton(configuration: configuration, primaryAction: buttonAction)
|
|
||||||
|
|
||||||
let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize)
|
let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize)
|
||||||
let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig)
|
let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig)
|
||||||
|
|
||||||
|
@ -36,11 +27,26 @@ struct SFSymbolButton: UIViewRepresentable {
|
||||||
|
|
||||||
button.setImage(focusedSymbolImage, for: .focused)
|
button.setImage(focusedSymbolImage, for: .focused)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> some UIButton {
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.cornerStyle = .capsule
|
||||||
|
|
||||||
|
let buttonAction = UIAction(title: "") { _ in
|
||||||
|
self.onSelect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let button = UIButton(configuration: configuration, primaryAction: buttonAction)
|
||||||
|
|
||||||
|
makeButtonConfig(button)
|
||||||
|
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: UIViewType, context: Context) {}
|
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||||
|
makeButtonConfig(uiView)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SFSymbolButton {
|
extension SFSymbolButton {
|
||||||
|
|
|
@ -28,7 +28,6 @@ extension VideoPlayer.Overlay.ActionButtons {
|
||||||
overlayTimer.start(5)
|
overlayTimer.start(5)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 30, maxHeight: 30)
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
.id(autoPlayEnabled)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,72 +30,73 @@ extension VideoPlayer {
|
||||||
@StateObject
|
@StateObject
|
||||||
private var overlayTimer: TimerProxy = .init()
|
private var overlayTimer: TimerProxy = .init()
|
||||||
|
|
||||||
var body: some View {
|
@ViewBuilder
|
||||||
ZStack {
|
private var currentOverlay: some View {
|
||||||
|
switch currentOverlayType {
|
||||||
MainOverlay()
|
case .chapters:
|
||||||
.visible(currentOverlayType == .main)
|
|
||||||
|
|
||||||
ConfirmCloseOverlay()
|
|
||||||
.visible(currentOverlayType == .confirmClose)
|
|
||||||
|
|
||||||
SmallMenuOverlay()
|
|
||||||
.visible(currentOverlayType == .smallMenu)
|
|
||||||
|
|
||||||
ChapterOverlay()
|
ChapterOverlay()
|
||||||
.visible(currentOverlayType == .chapters)
|
case .confirmClose:
|
||||||
|
ConfirmCloseOverlay()
|
||||||
|
case .main:
|
||||||
|
MainOverlay()
|
||||||
|
case .smallMenu:
|
||||||
|
SmallMenuOverlay()
|
||||||
}
|
}
|
||||||
.visible(isPresentingOverlay)
|
}
|
||||||
.animation(.linear(duration: 0.1), value: currentOverlayType)
|
|
||||||
.environment(\.currentOverlayType, $currentOverlayType)
|
var body: some View {
|
||||||
.environmentObject(overlayTimer)
|
currentOverlay
|
||||||
.onChange(of: currentOverlayType) { newValue in
|
.visible(isPresentingOverlay)
|
||||||
if [.smallMenu, .chapters].contains(newValue) {
|
.animation(.linear(duration: 0.1), value: currentOverlayType)
|
||||||
overlayTimer.pause()
|
.environment(\.currentOverlayType, $currentOverlayType)
|
||||||
} else if isPresentingOverlay {
|
.environmentObject(overlayTimer)
|
||||||
|
.onChange(of: currentOverlayType) { newValue in
|
||||||
|
if [.smallMenu, .chapters].contains(newValue) {
|
||||||
|
overlayTimer.pause()
|
||||||
|
} else if isPresentingOverlay {
|
||||||
|
overlayTimer.start(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: overlayTimer.isActive) { isActive in
|
||||||
|
guard !isActive else { return }
|
||||||
|
|
||||||
|
withAnimation(.linear(duration: 0.3)) {
|
||||||
|
isPresentingOverlay = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onSelectPressed {
|
||||||
|
currentOverlayType = .main
|
||||||
|
isPresentingOverlay = true
|
||||||
overlayTimer.start(5)
|
overlayTimer.start(5)
|
||||||
}
|
}
|
||||||
}
|
.onMenuPressed {
|
||||||
.onChange(of: overlayTimer.isActive) { isActive in
|
|
||||||
guard !isActive else { return }
|
|
||||||
|
|
||||||
withAnimation(.linear(duration: 0.3)) {
|
overlayTimer.start(5)
|
||||||
isPresentingOverlay = false
|
confirmCloseWorkItem?.cancel()
|
||||||
}
|
|
||||||
}
|
|
||||||
.onSelectPressed {
|
|
||||||
currentOverlayType = .main
|
|
||||||
isPresentingOverlay = true
|
|
||||||
overlayTimer.start(5)
|
|
||||||
}
|
|
||||||
.onMenuPressed {
|
|
||||||
|
|
||||||
overlayTimer.start(5)
|
if isPresentingOverlay && currentOverlayType == .confirmClose {
|
||||||
confirmCloseWorkItem?.cancel()
|
proxy.stop()
|
||||||
|
router.dismissCoordinator()
|
||||||
if isPresentingOverlay && currentOverlayType == .confirmClose {
|
} else if isPresentingOverlay && currentOverlayType == .smallMenu {
|
||||||
proxy.stop()
|
currentOverlayType = .main
|
||||||
router.dismissCoordinator()
|
} else {
|
||||||
} else if isPresentingOverlay && currentOverlayType == .smallMenu {
|
|
||||||
currentOverlayType = .main
|
|
||||||
} else {
|
|
||||||
withAnimation {
|
|
||||||
currentOverlayType = .confirmClose
|
|
||||||
isPresentingOverlay = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let task = DispatchWorkItem {
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
isPresentingOverlay = false
|
currentOverlayType = .confirmClose
|
||||||
overlayTimer.stop()
|
isPresentingOverlay = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let task = DispatchWorkItem {
|
||||||
|
withAnimation {
|
||||||
|
isPresentingOverlay = false
|
||||||
|
overlayTimer.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmCloseWorkItem = task
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task)
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmCloseWorkItem = task
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,6 @@ extension VideoPlayer {
|
||||||
|
|
||||||
enum MenuSection: String, Displayable {
|
enum MenuSection: String, Displayable {
|
||||||
case audio
|
case audio
|
||||||
case chapters
|
|
||||||
case playbackSpeed
|
case playbackSpeed
|
||||||
case subtitles
|
case subtitles
|
||||||
|
|
||||||
|
@ -22,8 +21,6 @@ extension VideoPlayer {
|
||||||
switch self {
|
switch self {
|
||||||
case .audio:
|
case .audio:
|
||||||
return "Audio"
|
return "Audio"
|
||||||
case .chapters:
|
|
||||||
return "Chapters"
|
|
||||||
case .playbackSpeed:
|
case .playbackSpeed:
|
||||||
return "Playback Speed"
|
return "Playback Speed"
|
||||||
case .subtitles:
|
case .subtitles:
|
||||||
|
@ -41,7 +38,7 @@ extension VideoPlayer {
|
||||||
private var focusedSection: MenuSection?
|
private var focusedSection: MenuSection?
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var lastFocusedSection: MenuSection?
|
private var lastFocusedSection: MenuSection = .subtitles
|
||||||
|
|
||||||
@StateObject
|
@StateObject
|
||||||
private var focusGuide: FocusGuide = .init()
|
private var focusGuide: FocusGuide = .init()
|
||||||
|
@ -50,24 +47,54 @@ extension VideoPlayer {
|
||||||
private var subtitleMenu: some View {
|
private var subtitleMenu: some View {
|
||||||
HStack {
|
HStack {
|
||||||
ForEach(viewModel.subtitleStreams, id: \.self) { mediaStream in
|
ForEach(viewModel.subtitleStreams, id: \.self) { mediaStream in
|
||||||
Button {} label: {
|
Button {
|
||||||
if videoPlayerManager.subtitleTrackIndex == mediaStream.index {
|
videoPlayerManager.subtitleTrackIndex = mediaStream.index ?? -1
|
||||||
Label(mediaStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark")
|
videoPlayerManager.proxy.setSubtitleTrack(.absolute(mediaStream.index ?? -1))
|
||||||
} else {
|
} label: {
|
||||||
Text(mediaStream.displayTitle ?? L10n.noTitle)
|
Label(
|
||||||
}
|
mediaStream.displayTitle ?? L10n.noTitle,
|
||||||
|
systemImage: videoPlayerManager.subtitleTrackIndex == mediaStream.index ? "checkmark.circle.fill" : "circle"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 80)
|
.modifier(MenuStyle(focusGuide: focusGuide))
|
||||||
.padding(.horizontal, 50)
|
}
|
||||||
.padding(.top)
|
|
||||||
.padding(.bottom, 45)
|
@ViewBuilder
|
||||||
.focusGuide(
|
private var audioMenu: some View {
|
||||||
focusGuide,
|
HStack {
|
||||||
tag: "contents",
|
ForEach(viewModel.audioStreams, id: \.self) { mediaStream in
|
||||||
top: "sections"
|
Button {
|
||||||
)
|
videoPlayerManager.audioTrackIndex = mediaStream.index ?? -1
|
||||||
|
videoPlayerManager.proxy.setAudioTrack(.absolute(mediaStream.index ?? -1))
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
mediaStream.displayTitle ?? L10n.noTitle,
|
||||||
|
systemImage: videoPlayerManager.audioTrackIndex == mediaStream.index ? "checkmark.circle.fill" : "circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(MenuStyle(focusGuide: focusGuide))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var playbackSpeedMenu: some View {
|
||||||
|
HStack {
|
||||||
|
ForEach(PlaybackSpeed.allCases, id: \.self) { speed in
|
||||||
|
Button {
|
||||||
|
videoPlayerManager.playbackSpeed = speed
|
||||||
|
videoPlayerManager.proxy.setRate(.absolute(Float(speed.rawValue)))
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
speed.displayTitle,
|
||||||
|
systemImage: speed == videoPlayerManager.playbackSpeed ? "checkmark.circle.fill" : "circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(MenuStyle(focusGuide: focusGuide))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
@ -98,14 +125,6 @@ extension VideoPlayer {
|
||||||
focused: $focusedSection,
|
focused: $focusedSection,
|
||||||
lastFocused: $lastFocusedSection
|
lastFocused: $lastFocusedSection
|
||||||
)
|
)
|
||||||
|
|
||||||
if !viewModel.chapters.isEmpty {
|
|
||||||
SectionButton(
|
|
||||||
section: .chapters,
|
|
||||||
focused: $focusedSection,
|
|
||||||
lastFocused: $lastFocusedSection
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.focusGuide(
|
.focusGuide(
|
||||||
focusGuide,
|
focusGuide,
|
||||||
|
@ -123,10 +142,10 @@ extension VideoPlayer {
|
||||||
switch lastFocusedSection {
|
switch lastFocusedSection {
|
||||||
case .subtitles:
|
case .subtitles:
|
||||||
subtitleMenu
|
subtitleMenu
|
||||||
default:
|
case .audio:
|
||||||
Button {
|
audioMenu
|
||||||
Text("None")
|
case .playbackSpeed:
|
||||||
}
|
playbackSpeedMenu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -151,7 +170,7 @@ extension VideoPlayer {
|
||||||
|
|
||||||
let section: MenuSection
|
let section: MenuSection
|
||||||
let focused: FocusState<MenuSection?>.Binding
|
let focused: FocusState<MenuSection?>.Binding
|
||||||
let lastFocused: Binding<MenuSection?>
|
let lastFocused: Binding<MenuSection>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
|
@ -170,5 +189,22 @@ extension VideoPlayer {
|
||||||
.focused(focused, equals: section)
|
.focused(focused, equals: section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MenuStyle: ViewModifier {
|
||||||
|
var focusGuide: FocusGuide
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.focusGuide(
|
||||||
|
focusGuide,
|
||||||
|
tag: "contents",
|
||||||
|
top: "sections"
|
||||||
|
)
|
||||||
|
.frame(height: 80)
|
||||||
|
.padding(.horizontal, 50)
|
||||||
|
.padding(.top)
|
||||||
|
.padding(.bottom, 45)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,10 +13,6 @@ extension VideoPlayer.Overlay.ActionButtons {
|
||||||
|
|
||||||
struct PlaybackSpeedMenu: View {
|
struct PlaybackSpeedMenu: View {
|
||||||
|
|
||||||
@Environment(\.playbackSpeed)
|
|
||||||
@Binding
|
|
||||||
private var playbackSpeed
|
|
||||||
|
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
private var overlayTimer: TimerProxy
|
private var overlayTimer: TimerProxy
|
||||||
@EnvironmentObject
|
@EnvironmentObject
|
||||||
|
@ -30,20 +26,16 @@ extension VideoPlayer.Overlay.ActionButtons {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(PlaybackSpeed.allCases, id: \.self) { speed in
|
ForEach(PlaybackSpeed.allCases, id: \.self) { speed in
|
||||||
Button {
|
Button {
|
||||||
playbackSpeed = Float(speed.rawValue)
|
videoPlayerManager.playbackSpeed = speed
|
||||||
videoPlayerProxy.setRate(.absolute(Float(speed.rawValue)))
|
videoPlayerProxy.setRate(.absolute(Float(speed.rawValue)))
|
||||||
} label: {
|
} label: {
|
||||||
if Float(speed.rawValue) == playbackSpeed {
|
if speed == videoPlayerManager.playbackSpeed {
|
||||||
Label(speed.displayTitle, systemImage: "checkmark")
|
Label(speed.displayTitle, systemImage: "checkmark")
|
||||||
} else {
|
} else {
|
||||||
Text(speed.displayTitle)
|
Text(speed.displayTitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !PlaybackSpeed.allCases.map(\.rawValue).contains(where: { $0 == Double(playbackSpeed) }) {
|
|
||||||
Label(String(format: "%.2f", playbackSpeed).appending("x"), systemImage: "checkmark")
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
content().eraseToAnyView()
|
content().eraseToAnyView()
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue