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