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