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:
Arkadiusz Fal
2026-07-30 23:59:26 +02:00
parent 6a0fe2fbbf
commit 8faf20c9e9
8 changed files with 238 additions and 4 deletions

View File

@@ -17435,6 +17435,26 @@
} }
} }
}, },
"stream.subtitles.loadFromFile" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Load subtitle from file…"
}
}
}
},
"stream.subtitles.loadFromFile.message" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choose a subtitle file"
}
}
}
},
"stream.subtitles.none" : { "stream.subtitles.none" : {
"localizations" : { "localizations" : {
"en" : { "en" : {

View File

@@ -18,6 +18,11 @@ struct Caption: Identifiable, Codable, Hashable, Sendable {
/// The URL to fetch the caption content /// The URL to fetch the caption content
let url: URL let url: URL
/// Original filename of a user-picked external subtitle file.
/// When set, it is used as the display name so two picked files with the
/// same language remain distinguishable from API captions and each other.
var pickedFileName: String? = nil
/// Whether this is an auto-generated caption /// Whether this is an auto-generated caption
var isAutoGenerated: Bool { var isAutoGenerated: Bool {
label.contains("auto-generated") label.contains("auto-generated")
@@ -37,6 +42,9 @@ struct Caption: Identifiable, Codable, Hashable, Sendable {
/// Formatted display name for the caption /// Formatted display name for the caption
var displayName: String { var displayName: String {
if let pickedFileName {
return pickedFileName
}
// Try to get localized language name (AUTO badge shown separately in UI) // Try to get localized language name (AUTO badge shown separately in UI)
if let localizedName = Locale.current.localizedString(forLanguageCode: baseLanguageCode) { if let localizedName = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
return localizedName return localizedName

View File

@@ -1446,6 +1446,67 @@ final class PlayerService {
loadCaption(caption) loadCaption(caption)
} }
/// Loads a user-picked external subtitle file (iOS document picker / macOS open panel).
/// Copies the file into the per-video temp subtitle directory (so mpv can read it
/// after the picker's security scope expires), registers it as a selectable
/// Caption for the current video, and activates it.
/// - Parameter pickedURL: The URL returned by the file picker.
func loadExternalSubtitleFile(from pickedURL: URL) {
guard let video = state.currentVideo, video.isFromMediaSource else { return }
let fileName = pickedURL.lastPathComponent
let didStartAccessing = pickedURL.startAccessingSecurityScopedResource()
defer {
if didStartAccessing {
pickedURL.stopAccessingSecurityScopedResource()
}
}
// Same hash-based directory scheme as SMBClient's subtitle pre-download:
// media-source video IDs contain slashes, so they can't be path components.
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("yattee-subtitles", isDirectory: true)
.appendingPathComponent(String(video.id.id.hashValue), isDirectory: true)
let destinationURL = tempDir.appendingPathComponent("picked-\(fileName)")
do {
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: pickedURL, to: destinationURL)
} catch {
LoggingService.shared.error(
"Failed to copy picked subtitle file \(fileName): \(error.localizedDescription)",
category: .player
)
return
}
// Detect language from a filename suffix like "Movie.en.srt" or "Movie_en.srt"
let baseName = pickedURL.deletingPathExtension().lastPathComponent
var languageCode = "und"
if let suffix = baseName.components(separatedBy: CharacterSet(charactersIn: "._")).last,
(2...3).contains(suffix.count),
Locale.current.localizedString(forLanguageCode: suffix) != nil {
languageCode = suffix.lowercased()
}
let caption = Caption(
label: baseName,
languageCode: languageCode,
url: destinationURL,
pickedFileName: fileName
)
// Re-picking the same file replaces its row instead of duplicating it
availableCaptions.removeAll { $0.id == caption.id }
availableCaptions.append(caption)
loadCaption(caption)
LoggingService.shared.logPlayer("Loaded external subtitle file: \(fileName)")
}
/// Loads online streams for the current video (when playing downloaded content). /// Loads online streams for the current video (when playing downloaded content).
/// After loading, the user can switch to an online stream from QualitySelectorView. /// After loading, the user can switch to an online stream from QualitySelectorView.
/// The downloaded stream is preserved and mixed in with online streams. /// The downloaded stream is preserved and mixed in with online streams.

View File

@@ -921,7 +921,7 @@ struct ControlsSectionRenderer: View {
@ViewBuilder @ViewBuilder
private var captionsButton: some View { 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 let subtitlesActive = actions.currentCaption != nil || actions.currentEmbeddedSubtitleTrackID != nil
controlButton(systemImage: subtitlesActive ? "captions.bubble.fill" : "captions.bubble") { controlButton(systemImage: subtitlesActive ? "captions.bubble.fill" : "captions.bubble") {
actions.onShowCaptionsSelector?() actions.onShowCaptionsSelector?()

View File

@@ -228,6 +228,16 @@ struct PlayerControlsActions {
!availableCaptions.isEmpty || !embeddedSubtitleTracks.isEmpty !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 /// Whether chapters are available
var hasChapters: Bool { var hasChapters: Bool {
!playerState.chapters.isEmpty !playerState.chapters.isEmpty

View 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

View File

@@ -957,11 +957,51 @@ extension QualitySelectorView {
.padding(.vertical, 8) .padding(.vertical, 8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
if canLoadExternalSubtitles {
Divider()
loadSubtitleFileRow
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
} }
.cardBackground() .cardBackground()
#endif #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 @ViewBuilder
private func embeddedSubtitleTrackRow(_ track: MPVTrack) -> some View { private func embeddedSubtitleTrackRow(_ track: MPVTrack) -> some View {
EmbeddedTrackRowView( EmbeddedTrackRowView(

View File

@@ -11,7 +11,7 @@ struct QualitySelectorView: View {
// MARK: - Environment // MARK: - Environment
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Environment(\.appEnvironment) private var appEnvironment @Environment(\.appEnvironment) var appEnvironment
// MARK: - Properties // MARK: - Properties
@@ -79,6 +79,9 @@ struct QualitySelectorView: View {
@State var selectedTab: QualitySelectorTab = .video @State var selectedTab: QualitySelectorTab = .video
@State var selectedVideoStream: Stream? @State var selectedVideoStream: Stream?
@State var selectedAudioStream: Stream? @State var selectedAudioStream: Stream?
#if os(iOS)
@State var showingSubtitleFilePicker = false
#endif
// MARK: - Settings Access // MARK: - Settings Access
@@ -114,12 +117,22 @@ struct QualitySelectorView: View {
if (hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 { if (hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 {
tabs.append(.audio) tabs.append(.audio)
} }
if !captions.isEmpty || !embeddedSubtitleTracks.isEmpty { if !captions.isEmpty || !embeddedSubtitleTracks.isEmpty || canLoadExternalSubtitles {
tabs.append(.subtitles) tabs.append(.subtitles)
} }
return tabs 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 /// Navigation title based on mode
var navigationTitle: String { var navigationTitle: String {
if showTabPicker { if showTabPicker {
@@ -144,7 +157,8 @@ struct QualitySelectorView: View {
/// Whether streams are empty (not loading, but no streams available) /// Whether streams are empty (not loading, but no streams available)
var hasNoStreams: Bool { var hasNoStreams: Bool {
if !showTabPicker && initialTab == .subtitles { if !showTabPicker && initialTab == .subtitles {
return !isLoading && captions.isEmpty && embeddedSubtitleTracks.isEmpty && !isPlayingDownloadedContent return !isLoading && captions.isEmpty && embeddedSubtitleTracks.isEmpty
&& !isPlayingDownloadedContent && !canLoadExternalSubtitles
} }
return !isLoading && streams.isEmpty && !isPlayingDownloadedContent return !isLoading && streams.isEmpty && !isPlayingDownloadedContent
} }
@@ -326,6 +340,13 @@ struct QualitySelectorView: View {
subtitlesDetailContent subtitlesDetailContent
} }
} }
#if os(iOS)
.sheet(isPresented: $showingSubtitleFilePicker) {
SubtitleFilePickerView { url in
handlePickedSubtitleFile(url)
}
}
#endif
.onAppear { .onAppear {
selectedVideoStream = currentStream selectedVideoStream = currentStream
// In audio mode the audio track IS the main stream // In audio mode the audio track IS the main stream