mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
Add loading external subtitle files for media-source playback
Adds a "Load subtitle from file…" row to the Subtitles section on iOS (document picker) and macOS (open panel), available when playing files from local folder, SMB, or WebDAV sources. The picked file is copied into the per-video temp subtitle directory, registered as a selectable Caption (displayed by filename), and activated through the existing loadCaption flow. The Subtitles tab and captions control button now also appear for media-source files without any subtitles.
This commit is contained in:
@@ -921,7 +921,7 @@ struct ControlsSectionRenderer: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var captionsButton: some View {
|
||||
if actions.hasCaptions, actions.onCaptionSelected != nil {
|
||||
if actions.hasCaptions || actions.canLoadExternalSubtitles, actions.onCaptionSelected != nil {
|
||||
let subtitlesActive = actions.currentCaption != nil || actions.currentEmbeddedSubtitleTrackID != nil
|
||||
controlButton(systemImage: subtitlesActive ? "captions.bubble.fill" : "captions.bubble") {
|
||||
actions.onShowCaptionsSelector?()
|
||||
|
||||
@@ -228,6 +228,16 @@ struct PlayerControlsActions {
|
||||
!availableCaptions.isEmpty || !embeddedSubtitleTracks.isEmpty
|
||||
}
|
||||
|
||||
/// Whether an external subtitle file can be loaded: media-source playback
|
||||
/// (local folder/SMB/WebDAV) on platforms with a file picker.
|
||||
var canLoadExternalSubtitles: Bool {
|
||||
#if os(tvOS)
|
||||
return false
|
||||
#else
|
||||
return currentVideo?.isFromMediaSource == true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Whether chapters are available
|
||||
var hasChapters: Bool {
|
||||
!playerState.chapters.isEmpty
|
||||
|
||||
74
Yattee/Views/Player/QualitySelector/SubtitleFilePicker.swift
Normal file
74
Yattee/Views/Player/QualitySelector/SubtitleFilePicker.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// SubtitleFilePicker.swift
|
||||
// Yattee
|
||||
//
|
||||
// File pickers for loading an external subtitle file into the current playback.
|
||||
// iOS uses a document picker sheet, macOS an NSOpenPanel. Not available on tvOS.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
#if !os(tvOS)
|
||||
|
||||
/// Content types accepted by the subtitle file pickers.
|
||||
enum SubtitleFileTypes {
|
||||
/// Dynamic UTTypes for the supported subtitle extensions (srt/vtt/ass/ssa/sub —
|
||||
/// none of them have built-in UTType constants).
|
||||
static var contentTypes: [UTType] {
|
||||
let types = MediaFile.subtitleExtensions.compactMap { UTType(filenameExtension: $0) }
|
||||
return types.isEmpty ? [.data] : types
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
struct SubtitleFilePickerView: UIViewControllerRepresentable {
|
||||
let onSelect: (URL) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: SubtitleFileTypes.contentTypes)
|
||||
picker.delegate = context.coordinator
|
||||
picker.allowsMultipleSelection = false
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onSelect: onSelect)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
let onSelect: (URL) -> Void
|
||||
|
||||
init(onSelect: @escaping (URL) -> Void) {
|
||||
self.onSelect = onSelect
|
||||
}
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first else { return }
|
||||
onSelect(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
enum SubtitleFilePanel {
|
||||
@MainActor
|
||||
static func present(onSelect: (URL) -> Void) {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = SubtitleFileTypes.contentTypes
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.message = String(localized: "stream.subtitles.loadFromFile.message")
|
||||
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
onSelect(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -957,11 +957,51 @@ extension QualitySelectorView {
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
if canLoadExternalSubtitles {
|
||||
Divider()
|
||||
|
||||
loadSubtitleFileRow
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
.cardBackground()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// Row that opens a file picker to load an external subtitle file into
|
||||
/// the current media-source playback.
|
||||
private var loadSubtitleFileRow: some View {
|
||||
Button {
|
||||
#if os(iOS)
|
||||
showingSubtitleFilePicker = true
|
||||
#elseif os(macOS)
|
||||
SubtitleFilePanel.present { url in
|
||||
handlePickedSubtitleFile(url)
|
||||
}
|
||||
#endif
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("stream.subtitles.loadFromFile")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func handlePickedSubtitleFile(_ url: URL) {
|
||||
appEnvironment?.playerService.loadExternalSubtitleFile(from: url)
|
||||
performDismiss()
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
private func embeddedSubtitleTrackRow(_ track: MPVTrack) -> some View {
|
||||
EmbeddedTrackRowView(
|
||||
|
||||
@@ -11,7 +11,7 @@ struct QualitySelectorView: View {
|
||||
// MARK: - Environment
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@Environment(\.appEnvironment) var appEnvironment
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@@ -79,6 +79,9 @@ struct QualitySelectorView: View {
|
||||
@State var selectedTab: QualitySelectorTab = .video
|
||||
@State var selectedVideoStream: Stream?
|
||||
@State var selectedAudioStream: Stream?
|
||||
#if os(iOS)
|
||||
@State var showingSubtitleFilePicker = false
|
||||
#endif
|
||||
|
||||
// MARK: - Settings Access
|
||||
|
||||
@@ -114,12 +117,22 @@ struct QualitySelectorView: View {
|
||||
if (hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 {
|
||||
tabs.append(.audio)
|
||||
}
|
||||
if !captions.isEmpty || !embeddedSubtitleTracks.isEmpty {
|
||||
if !captions.isEmpty || !embeddedSubtitleTracks.isEmpty || canLoadExternalSubtitles {
|
||||
tabs.append(.subtitles)
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
|
||||
/// Whether the "Load subtitle from file…" row is available: media-source
|
||||
/// playback (local folder/SMB/WebDAV) on platforms with a file picker.
|
||||
var canLoadExternalSubtitles: Bool {
|
||||
#if os(tvOS)
|
||||
return false
|
||||
#else
|
||||
return appEnvironment?.playerService.state.currentVideo?.isFromMediaSource == true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Navigation title based on mode
|
||||
var navigationTitle: String {
|
||||
if showTabPicker {
|
||||
@@ -144,7 +157,8 @@ struct QualitySelectorView: View {
|
||||
/// Whether streams are empty (not loading, but no streams available)
|
||||
var hasNoStreams: Bool {
|
||||
if !showTabPicker && initialTab == .subtitles {
|
||||
return !isLoading && captions.isEmpty && embeddedSubtitleTracks.isEmpty && !isPlayingDownloadedContent
|
||||
return !isLoading && captions.isEmpty && embeddedSubtitleTracks.isEmpty
|
||||
&& !isPlayingDownloadedContent && !canLoadExternalSubtitles
|
||||
}
|
||||
return !isLoading && streams.isEmpty && !isPlayingDownloadedContent
|
||||
}
|
||||
@@ -326,6 +340,13 @@ struct QualitySelectorView: View {
|
||||
subtitlesDetailContent
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showingSubtitleFilePicker) {
|
||||
SubtitleFilePickerView { url in
|
||||
handlePickedSubtitleFile(url)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.onAppear {
|
||||
selectedVideoStream = currentStream
|
||||
// In audio mode the audio track IS the main stream
|
||||
|
||||
Reference in New Issue
Block a user