From 8faf20c9e9baaf0512815cf2b30da438a507733a Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 30 Jul 2026 23:59:26 +0200 Subject: [PATCH] Add loading external subtitle files for media-source playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Yattee/Localizable.xcstrings | 20 +++++ Yattee/Models/Caption.swift | 8 ++ Yattee/Services/Player/PlayerService.swift | 61 +++++++++++++++ .../Player/ControlsSectionRenderer.swift | 2 +- .../Views/Player/PlayerControlsActions.swift | 10 +++ .../QualitySelector/SubtitleFilePicker.swift | 74 +++++++++++++++++++ .../Player/QualitySelectorView+Sections.swift | 40 ++++++++++ Yattee/Views/Player/QualitySelectorView.swift | 27 ++++++- 8 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 Yattee/Views/Player/QualitySelector/SubtitleFilePicker.swift diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 4674cc8f..3d163892 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { "localizations" : { "en" : { diff --git a/Yattee/Models/Caption.swift b/Yattee/Models/Caption.swift index ea7a5f76..7568df4e 100644 --- a/Yattee/Models/Caption.swift +++ b/Yattee/Models/Caption.swift @@ -18,6 +18,11 @@ struct Caption: Identifiable, Codable, Hashable, Sendable { /// The URL to fetch the caption content 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 var isAutoGenerated: Bool { label.contains("auto-generated") @@ -37,6 +42,9 @@ struct Caption: Identifiable, Codable, Hashable, Sendable { /// Formatted display name for the caption var displayName: String { + if let pickedFileName { + return pickedFileName + } // Try to get localized language name (AUTO badge shown separately in UI) if let localizedName = Locale.current.localizedString(forLanguageCode: baseLanguageCode) { return localizedName diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 59599f3a..f79c6e73 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -1446,6 +1446,67 @@ final class PlayerService { 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). /// After loading, the user can switch to an online stream from QualitySelectorView. /// The downloaded stream is preserved and mixed in with online streams. diff --git a/Yattee/Views/Player/ControlsSectionRenderer.swift b/Yattee/Views/Player/ControlsSectionRenderer.swift index af563c83..fd56db16 100644 --- a/Yattee/Views/Player/ControlsSectionRenderer.swift +++ b/Yattee/Views/Player/ControlsSectionRenderer.swift @@ -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?() diff --git a/Yattee/Views/Player/PlayerControlsActions.swift b/Yattee/Views/Player/PlayerControlsActions.swift index 0075af13..3e95a69d 100644 --- a/Yattee/Views/Player/PlayerControlsActions.swift +++ b/Yattee/Views/Player/PlayerControlsActions.swift @@ -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 diff --git a/Yattee/Views/Player/QualitySelector/SubtitleFilePicker.swift b/Yattee/Views/Player/QualitySelector/SubtitleFilePicker.swift new file mode 100644 index 00000000..1d111203 --- /dev/null +++ b/Yattee/Views/Player/QualitySelector/SubtitleFilePicker.swift @@ -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 diff --git a/Yattee/Views/Player/QualitySelectorView+Sections.swift b/Yattee/Views/Player/QualitySelectorView+Sections.swift index c4de31e1..5f59f881 100644 --- a/Yattee/Views/Player/QualitySelectorView+Sections.swift +++ b/Yattee/Views/Player/QualitySelectorView+Sections.swift @@ -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( diff --git a/Yattee/Views/Player/QualitySelectorView.swift b/Yattee/Views/Player/QualitySelectorView.swift index 440c96f8..3e9a8c1f 100644 --- a/Yattee/Views/Player/QualitySelectorView.swift +++ b/Yattee/Views/Player/QualitySelectorView.swift @@ -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