diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index 6d23a3f4..d0a94b41 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -502,7 +502,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { keywords: json["keywords"].arrayValue.compactMap { $0.string }, streams: extractStreams(from: json), related: extractRelated(from: json), - chapters: extractChapters(from: description), + chapters: createChapters(from: description, thumbnails: json), captions: extractCaptions(from: json) ) } @@ -575,6 +575,22 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } } + private func createChapters(from description: String, thumbnails: JSON) -> [Chapter] { + var chapters = extractChapters(from: description) + + if !chapters.isEmpty { + let thumbnailsData = extractThumbnails(from: thumbnails) + let thumbnailURL = thumbnailsData.first { $0.quality == .medium }?.url + + for chapter in chapters.indices { + if let url = thumbnailURL { + chapters[chapter].image = url + } + } + } + return chapters + } + private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"] private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage { diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index 31f8705c..a7614956 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -152,58 +152,94 @@ extension VideosAPI { /* The following chapter patterns are covered: - start - end - title / start - end: Title / start - end title - start - title / start: title / start title / [start] - title / [start]: title / [start] title - index. title - start / index. title start - title: (start) + 1) "start - end - title" / "start - end: Title" / "start - end title" + 2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title" + 3) "index. title - start" / "index. title start" + 4) "title: (start)" + 5) "(start) title" - The order is important! + These represent: + + - "start" and "end" are timestamps, defining the start and end of the individual chapter + - "title" is the name of the chapter + - "index" is the chapter's position in a list + + The order of these patterns is important as it determines the priority. The patterns listed first have a higher priority. + In the case of multiple matches, the pattern with the highest priority will be chosen - lower number means higher priority. */ let patterns = [ "(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?.*)(?=\\n|$)", "(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)", "(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)", - "(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)" + "(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)", + "(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)" ] - for pattern in patterns { - guard let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { continue } - let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description)) + let extractChaptersGroup = DispatchGroup() + var capturedChapters: [Int: [Chapter]] = [:] + let lock = NSLock() - if !chapterLines.isEmpty { - return chapterLines.compactMap { line in - let titleRange = line.range(withName: "title") - let startRange = line.range(withName: "start") - guard let titleSubstringRange = Range(titleRange, in: description), - let startSubstringRange = Range(startRange, in: description) - else { - return nil - } - let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces) - let startCapture = String(description[startSubstringRange]) - let startComponents = startCapture.components(separatedBy: ":") - guard startComponents.count <= 3 else { return nil } + for (index, pattern) in patterns.enumerated() { + extractChaptersGroup.enter() + DispatchQueue.global().async { + if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { + let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description)) + let extractedChapters = chapterLines.compactMap { line -> Chapter? in + let titleRange = line.range(withName: "title") + let startRange = line.range(withName: "start") - var hours: Double? - var minutes: Double? - var seconds: Double? + guard let titleSubstringRange = Range(titleRange, in: description), + let startSubstringRange = Range(startRange, in: description) + else { + return nil + } - if startComponents.count == 3 { - hours = Double(startComponents[0]) - minutes = Double(startComponents[1]) - seconds = Double(startComponents[2]) - } else if startComponents.count == 2 { - minutes = Double(startComponents[0]) - seconds = Double(startComponents[1]) + let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces) + let startCapture = String(description[startSubstringRange]) + let startComponents = startCapture.components(separatedBy: ":") + guard startComponents.count <= 3 else { return nil } + + var hours: Double? + var minutes: Double? + var seconds: Double? + + if startComponents.count == 3 { + hours = Double(startComponents[0]) + minutes = Double(startComponents[1]) + seconds = Double(startComponents[2]) + } else if startComponents.count == 2 { + minutes = Double(startComponents[0]) + seconds = Double(startComponents[1]) + } + + guard var startSeconds = seconds else { return nil } + + startSeconds += (minutes ?? 0) * 60 + startSeconds += (hours ?? 0) * 60 * 60 + + return Chapter(title: titleCapture, start: startSeconds) } - guard var startSeconds = seconds else { return nil } - - startSeconds += (minutes ?? 0) * 60 - startSeconds += (hours ?? 0) * 60 * 60 - - return .init(title: titleCapture, start: startSeconds) + if !extractedChapters.isEmpty { + lock.lock() + capturedChapters[index] = extractedChapters + lock.unlock() + } } + extractChaptersGroup.leave() + } + } + + extractChaptersGroup.wait() + + // Now we sort the keys of the capturedChapters dictionary. + // These keys correspond to the priority of each pattern. + let sortedKeys = Array(capturedChapters.keys).sorted(by: <) + + // Return first non-empty result in the order of patterns + for key in sortedKeys { + if let chapters = capturedChapters[key], !chapters.isEmpty { + return chapters } } return [] diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 01a9adb7..3fd47092 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -217,9 +217,22 @@ final class MPVBackend: PlayerBackend { #endif var captions: Captions? - if let captionsLanguageCode = Defaults[.captionsLanguageCode] { - captions = video.captions.first { $0.code == captionsLanguageCode } ?? - video.captions.first { $0.code.contains(captionsLanguageCode) } + + if Defaults[.captionsAutoShow] == true { + let captionsDefaultLanguageCode = Defaults[.captionsDefaultLanguageCode], + captionsFallbackLanguageCode = Defaults[.captionsFallbackLanguageCode] + + // Try to get captions with the default language code first + captions = video.captions.first { $0.code == captionsDefaultLanguageCode } ?? + video.captions.first { $0.code.contains(captionsDefaultLanguageCode) } + + // If there are still no captions, try to get captions with the fallback language code + if captions.isNil && !captionsFallbackLanguageCode.isEmpty { + captions = video.captions.first { $0.code == captionsFallbackLanguageCode } ?? + video.captions.first { $0.code.contains(captionsFallbackLanguageCode) } + } + } else { + captions = nil } let updateCurrentStream = { @@ -254,9 +267,8 @@ final class MPVBackend: PlayerBackend { self.startClientUpdates() - // Captions should only be displayed when selected by the user, - // not when the video starts. So, we remove them. - self.client?.removeSubs() + if Defaults[.captionsAutoShow] { self.client?.setSubToAuto() } else { self.client?.setSubToNo() } + PlayerModel.shared.captions = self.captions if !preservingTime, !upgrading, diff --git a/Model/Player/Backends/MPVClient.swift b/Model/Player/Backends/MPVClient.swift index b5695328..2b4ba38c 100644 --- a/Model/Player/Backends/MPVClient.swift +++ b/Model/Player/Backends/MPVClient.swift @@ -68,6 +68,8 @@ final class MPVClient: ObservableObject { checkError(mpv_set_option_string(mpv, "vo", "libmpv")) checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1")) checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no")) + checkError(mpv_set_option_string(mpv, "sub-scale", Defaults[.captionsFontScaleSize])) + checkError(mpv_set_option_string(mpv, "sub-color", Defaults[.captionsFontColor])) checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent)) checkError(mpv_initialize(mpv)) @@ -406,6 +408,22 @@ final class MPVClient: ObservableObject { setString("video", "no") } + func setSubToAuto() { + setString("sub", "auto") + } + + func setSubToNo() { + setString("sub", "no") + } + + func setSubFontSize(scaleSize: String) { + setString("sub-scale", scaleSize) + } + + func setSubFontColor(color: String) { + setString("sub-color", color) + } + var tracksCount: Int { Int(getString("track-list/count") ?? "-1") ?? -1 } diff --git a/Model/Player/Backends/PlayerBackend.swift b/Model/Player/Backends/PlayerBackend.swift index 980e9ab5..18c6ca3d 100644 --- a/Model/Player/Backends/PlayerBackend.swift +++ b/Model/Player/Backends/PlayerBackend.swift @@ -133,23 +133,22 @@ extension PlayerBackend { } func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? { - // filter out non HLS streams - let nonHLSStreams = streams.filter { $0.kind != .hls } + // filter out non-HLS streams and streams with resolution more than maxResolution + let nonHLSStreams = streams.filter { + $0.kind != .hls && $0.resolution <= maxResolution.value + } - // find max resolution from non HLS streams - let bestResolution = nonHLSStreams - .filter { $0.resolution <= maxResolution.value } - .max { $0.resolution < $1.resolution } + // find max resolution and bitrate from non-HLS streams + let bestResolutionStream = nonHLSStreams.max { $0.resolution < $1.resolution } + let bestBitrateStream = nonHLSStreams.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 } - // finde max bitrate from non HLS streams - let bestBitrate = nonHLSStreams - .filter { $0.resolution <= maxResolution.value } - .max { $0.bitrate ?? 0 < $1.bitrate ?? 0 } + let bestResolution = bestResolutionStream?.resolution ?? maxResolution.value + let bestBitrate = bestBitrateStream?.bitrate ?? bestResolutionStream?.resolution.bitrate ?? maxResolution.value.bitrate return streams.map { stream in if stream.kind == .hls { - stream.resolution = bestResolution?.resolution ?? maxResolution.value - stream.bitrate = bestBitrate?.bitrate ?? (bestResolution?.resolution.bitrate ?? maxResolution.value.bitrate) + stream.resolution = bestResolution + stream.bitrate = bestBitrate stream.format = .hls } else if stream.kind == .stream { stream.format = .stream diff --git a/Model/Player/PlayerStreams.swift b/Model/Player/PlayerStreams.swift index abc3a861..4fc91740 100644 --- a/Model/Player/PlayerStreams.swift +++ b/Model/Player/PlayerStreams.swift @@ -196,11 +196,13 @@ extension PlayerModel { } } - func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool { - if lhs.resolution.isNil || rhs.resolution.isNil { + func streamsSorter(lhs: Stream, rhs: Stream) -> Bool { + // Use optional chaining to simplify nil handling + guard let lhsRes = lhs.resolution?.height, let rhsRes = rhs.resolution?.height else { return lhs.kind < rhs.kind } - return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind) + // Compare either kind or resolution based on conditions + return lhs.kind == rhs.kind ? (lhsRes > rhsRes) : (lhs.kind < rhs.kind) } } diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index c2cc9d23..971ac876 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -301,7 +301,12 @@ extension Defaults.Keys { static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed") static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv) + static let captionsAutoShow = Key<Bool>("captionsAutoShow", default: false) static let captionsLanguageCode = Key<String?>("captionsLanguageCode") + static let captionsDefaultLanguageCode = Key<String>("captionsDefaultLanguageCode", default: LanguageCodes.English.rawValue) + static let captionsFallbackLanguageCode = Key<String>("captionsDefaultFallbackCode", default: LanguageCodes.English.rawValue) + static let captionsFontScaleSize = Key<String>("captionsFontScale", default: "1.0") + static let captionsFontColor = Key<String>("captionsFontColor", default: "#FFFFFF") static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID") static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false) diff --git a/Shared/LanguageCodes.swift b/Shared/LanguageCodes.swift new file mode 100644 index 00000000..74fd00e5 --- /dev/null +++ b/Shared/LanguageCodes.swift @@ -0,0 +1,109 @@ +enum LanguageCodes: String, CaseIterable { + case Afrikaans = "af" + case Arabic = "ar" + case Azerbaijani = "az" + case Bengali = "bn" + case Catalan = "ca" + case Czech = "cs" + case Welsh = "cy" + case Danish = "da" + case German = "de" + case Greek = "el" + case English = "en" + case English_GB = "en-GB" + case Spanish = "es" + case Persian = "fa" + case Finnish = "fi" + case Filipino = "fil" + case French = "fr" + case Irish = "ga" + case Hebrew = "he" + case Hindi = "hi" + case Hungarian = "hu" + case Indonesian = "id" + case Italian = "it" + case Japanese = "ja" + case Javanese = "jv" + case Korean = "ko" + case Lithuanian = "lt" + case Malay = "ms" + case Maltese = "mt" + case Dutch = "nl" + case Norwegian = "no" + case Polish = "pl" + case Portuguese = "pt" + case Romanian = "ro" + case Russian = "ru" + case Slovak = "sk" + case Slovene = "sl" + case Swedish = "sv" + case Swahili = "sw" + case Thai = "th" + case Tagalog = "tl" + case Turkish = "tr" + case Ukrainian = "uk" + case Urdu = "ur" + case Uzbek = "uz" + case Vietnamese = "vi" + case Xhosa = "xh" + case Chinese = "zh" + case Zulu = "zu" + + var description: String { + switch self { + case .Afrikaans: return "Afrikaans" + case .Arabic: return "Arabic" + case .Azerbaijani: return "Azerbaijani" + case .Bengali: return "Bengali" + case .Catalan: return "Catalan" + case .Czech: return "Czech" + case .Welsh: return "Welsh" + case .Danish: return "Danish" + case .German: return "German" + case .Greek: return "Greek" + case .English: return "English" + case .English_GB: return "English (United Kingdom)" + case .Spanish: return "Spanish" + case .Persian: return "Persian" + case .Finnish: return "Finnish" + case .Filipino: return "Filipino" + case .French: return "French" + case .Irish: return "Irish" + case .Hebrew: return "Hebrew" + case .Hindi: return "Hindi" + case .Hungarian: return "Hungarian" + case .Indonesian: return "Indonesian" + case .Italian: return "Italian" + case .Japanese: return "Japanese" + case .Javanese: return "Javanese" + case .Korean: return "Korean" + case .Lithuanian: return "Lithuanian" + case .Malay: return "Malay" + case .Maltese: return "Maltese" + case .Dutch: return "Dutch" + case .Norwegian: return "Norwegian" + case .Polish: return "Polish" + case .Portuguese: return "Portuguese" + case .Romanian: return "Romanian" + case .Russian: return "Russian" + case .Slovak: return "Slovak" + case .Slovene: return "Slovene" + case .Swedish: return "Swedish" + case .Swahili: return "Swahili" + case .Thai: return "Thai" + case .Tagalog: return "Tagalog" + case .Turkish: return "Turkish" + case .Ukrainian: return "Ukrainian" + case .Urdu: return "Urdu" + case .Uzbek: return "Uzbek" + case .Vietnamese: return "Vietnamese" + case .Xhosa: return "Xhosa" + case .Chinese: return "Chinese" + case .Zulu: return "Zulu" + } + } + + static func languageName(for code: String) -> String { + return LanguageCodes(rawValue: code)?.description ?? "Unknown" + } +} diff --git a/Shared/Player/Controls/ControlsOverlay.swift b/Shared/Player/Controls/ControlsOverlay.swift index 6de44e8a..df0e00f6 100644 --- a/Shared/Player/Controls/ControlsOverlay.swift +++ b/Shared/Player/Controls/ControlsOverlay.swift @@ -11,16 +11,16 @@ struct ControlsOverlay: View { @Default(.qualityProfiles) private var qualityProfiles #if os(tvOS) - enum Field: Hashable { - case qualityProfile - case stream - case increaseRate - case decreaseRate - case captions - } + enum Field: Hashable { + case qualityProfile + case stream + case increaseRate + case decreaseRate + case captions + } - @FocusState private var focusedField: Field? - @State private var presentingButtonHintAlert = false + @FocusState private var focusedField: Field? + @State private var presentingButtonHintAlert = false #endif var body: some View { @@ -94,10 +94,10 @@ struct ControlsOverlay: View { #endif #if os(tvOS) - Text("Press and hold remote button to open captions and quality menus") - .frame(maxWidth: 400) - .font(.caption) - .foregroundColor(.secondary) + Text("Press and hold remote button to open captions and quality menus") + .frame(maxWidth: 400) + .font(.caption) + .foregroundColor(.secondary) #endif } .frame(maxHeight: overlayHeight) @@ -117,9 +117,9 @@ struct ControlsOverlay: View { private var overlayHeight: Double { #if os(tvOS) - contentSize.height + 80.0 + contentSize.height + 80.0 #else - contentSize.height + contentSize.height #endif } @@ -160,26 +160,26 @@ struct ControlsOverlay: View { @ViewBuilder private var rateButton: some View { #if os(macOS) - ratePicker - .labelsHidden() - .frame(maxWidth: 100) + ratePicker + .labelsHidden() + .frame(maxWidth: 100) #elseif os(iOS) - Menu { - ratePicker - } label: { - Text(player.rateLabel(player.currentRate)) - .foregroundColor(.primary) - .frame(width: 123) - } - .transaction { t in t.animation = .none } - .buttonStyle(.plain) - .foregroundColor(.primary) - .frame(width: 123, height: 40) - .modifier(ControlBackgroundModifier()) - .mask(RoundedRectangle(cornerRadius: 3)) - #else + Menu { + ratePicker + } label: { Text(player.rateLabel(player.currentRate)) - .frame(minWidth: 120) + .foregroundColor(.primary) + .frame(width: 123) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(width: 123, height: 40) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) + #else + Text(player.rateLabel(player.currentRate)) + .frame(minWidth: 120) #endif } @@ -241,50 +241,50 @@ struct ControlsOverlay: View { private var rateButtonsSpacing: Double { #if os(tvOS) - 10 + 10 #else - 8 + 8 #endif } @ViewBuilder private var qualityProfileButton: some View { #if os(macOS) - qualityProfilePicker - .labelsHidden() - .frame(maxWidth: 300) + qualityProfilePicker + .labelsHidden() + .frame(maxWidth: 300) #elseif os(iOS) - Menu { - qualityProfilePicker - } label: { - Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) - .frame(maxWidth: 240) - } - .transaction { t in t.animation = .none } - .buttonStyle(.plain) - .foregroundColor(.primary) - .frame(maxWidth: 240) - .frame(height: 40) - .modifier(ControlBackgroundModifier()) - .mask(RoundedRectangle(cornerRadius: 3)) + Menu { + qualityProfilePicker + } label: { + Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) + .frame(maxWidth: 240) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(maxWidth: 240) + .frame(height: 40) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) #else - ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) { - Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) - .lineLimit(1) - .frame(maxWidth: 320) - } - .contextMenu { - Button("Automatic") { player.qualityProfileSelection = nil } + ControlsOverlayButton(focusedField: $focusedField, field: .qualityProfile) { + Text(player.qualityProfileSelection?.description ?? "Automatic".localized()) + .lineLimit(1) + .frame(maxWidth: 320) + } + .contextMenu { + Button("Automatic") { player.qualityProfileSelection = nil } - ForEach(qualityProfiles) { qualityProfile in - Button { - player.qualityProfileSelection = qualityProfile - } label: { - Text(qualityProfile.description) - } - - Button("Cancel", role: .cancel) {} + ForEach(qualityProfiles) { qualityProfile in + Button { + player.qualityProfileSelection = qualityProfile + } label: { + Text(qualityProfile.description) } + + Button("Cancel", role: .cancel) {} } + } #endif } @@ -300,71 +300,91 @@ struct ControlsOverlay: View { @ViewBuilder private var qualityButton: some View { #if os(macOS) - StreamControl() - .labelsHidden() - .frame(maxWidth: 300) + StreamControl() + .labelsHidden() + .frame(maxWidth: 300) #elseif os(iOS) - Menu { - StreamControl() - } label: { - Text(player.streamSelection?.resolutionAndFormat ?? "loading") - .frame(width: 140, height: 40) - .foregroundColor(.primary) - } - .transaction { t in t.animation = .none } - .buttonStyle(.plain) - .foregroundColor(.primary) - .frame(width: 240, height: 40) - .modifier(ControlBackgroundModifier()) - .mask(RoundedRectangle(cornerRadius: 3)) + Menu { + StreamControl() + } label: { + Text(player.streamSelection?.resolutionAndFormat ?? "loading") + .frame(width: 140, height: 40) + .foregroundColor(.primary) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(width: 240, height: 40) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) #else - StreamControl(focusedField: $focusedField) + StreamControl(focusedField: $focusedField) #endif } @ViewBuilder private var captionsButton: some View { #if os(macOS) - captionsPicker - .labelsHidden() - .frame(maxWidth: 300) + captionsPicker + .labelsHidden() + .frame(maxWidth: 300) #elseif os(iOS) - Menu { - captionsPicker - } label: { - HStack(spacing: 4) { - Image(systemName: "text.bubble") - if let captions = captionsBinding.wrappedValue { - Text(captions.code) - .foregroundColor(.primary) - } - } - .frame(width: 240) - .frame(height: 40) - } - .transaction { t in t.animation = .none } - .buttonStyle(.plain) - .foregroundColor(.primary) - .frame(width: 240) - .modifier(ControlBackgroundModifier()) - .mask(RoundedRectangle(cornerRadius: 3)) - #else - ControlsOverlayButton(focusedField: $focusedField, field: .captions) { - HStack(spacing: 8) { - Image(systemName: "text.bubble") - if let captions = captionsBinding.wrappedValue { - Text(captions.code) - } - } - .frame(maxWidth: 320) - } - .contextMenu { - Button("Disabled") { captionsBinding.wrappedValue = nil } + Menu { + captionsPicker + } label: { + HStack(spacing: 4) { + Image(systemName: "text.bubble") + if let captions = captionsBinding.wrappedValue, + let language = LanguageCodes(rawValue: captions.code) - ForEach(player.currentVideo?.captions ?? []) { caption in - Button(caption.description) { captionsBinding.wrappedValue = caption } + { + Text("\(language.description.capitalized) (\(language.rawValue))") + .foregroundColor(.accentColor) + } else { + if captionsBinding.wrappedValue == nil { + Text("Not available") + } else { + Text("Disabled") + .foregroundColor(.accentColor) + } } - Button("Cancel", role: .cancel) {} } + .frame(width: 240) + .frame(height: 40) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(width: 240) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) + #else + ControlsOverlayButton(focusedField: $focusedField, field: .captions) { + HStack(spacing: 8) { + Image(systemName: "text.bubble") + if let captions = captionsBinding.wrappedValue, + let language = LanguageCodes(rawValue: captions.code) + { + Text("\(language.description.capitalized) (\(language.rawValue))") + .foregroundColor(.accentColor) + } else { + if captionsBinding.wrappedValue == nil { + Text("Not available") + } else { + Text("Disabled") + .foregroundColor(.accentColor) + } + } + } + .frame(maxWidth: 320) + } + .contextMenu { + Button("Disabled") { captionsBinding.wrappedValue = nil } + + ForEach(player.currentVideo?.captions ?? []) { caption in + Button(caption.description) { captionsBinding.wrappedValue = caption } + } + Button("Cancel", role: .cancel) {} + } #endif } diff --git a/Shared/Player/PlaybackSettings.swift b/Shared/Player/PlaybackSettings.swift index 66638b11..c9861235 100644 --- a/Shared/Player/PlaybackSettings.swift +++ b/Shared/Player/PlaybackSettings.swift @@ -1,3 +1,4 @@ +import Combine import Defaults import SwiftUI @@ -383,23 +384,35 @@ struct PlaybackSettings: View { } @ViewBuilder private var captionsButton: some View { + let videoCaptions = player.currentVideo?.captions #if os(macOS) captionsPicker .labelsHidden() .frame(maxWidth: 300) #elseif os(iOS) Menu { - captionsPicker + if videoCaptions?.isEmpty == false { + captionsPicker + } } label: { HStack(spacing: 4) { Image(systemName: "text.bubble") - if let captions = player.captions { - Text(captions.code) + if let captions = player.captions, + let language = LanguageCodes(rawValue: captions.code) + { + Text("\(language.description.capitalized) (\(language.rawValue))") .foregroundColor(.accentColor) + } else { + if videoCaptions?.isEmpty == true { + Text("Not available") + } else { + Text("Disabled") + } } } .frame(alignment: .trailing) .frame(height: 40) + .disabled(videoCaptions?.isEmpty == true) } .transaction { t in t.animation = .none } .buttonStyle(.plain) diff --git a/Shared/Player/Video Details/ChapterView.swift b/Shared/Player/Video Details/ChapterView.swift index 695807d4..f4b9c831 100644 --- a/Shared/Player/Video Details/ChapterView.swift +++ b/Shared/Player/Video Details/ChapterView.swift @@ -65,7 +65,7 @@ import SwiftUI } static var thumbnailHeight: Double { - thumbnailWidth / 1.7777 + thumbnailWidth / (16 / 9) } } diff --git a/Shared/Settings/PlayerSettings.swift b/Shared/Settings/PlayerSettings.swift index eaa890f9..9c1aefdc 100644 --- a/Shared/Settings/PlayerSettings.swift +++ b/Shared/Settings/PlayerSettings.swift @@ -30,12 +30,19 @@ struct PlayerSettings: View { @Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike + @Default(.showRelated) private var showRelated @Default(.showInspector) private var showInspector + @Default(.showChapters) private var showChapters @Default(.showChapterThumbnails) private var showThumbnails @Default(.showChapterThumbnailsOnlyWhenDifferent) private var showThumbnailsOnlyWhenDifferent @Default(.expandChapters) private var expandChapters - @Default(.showRelated) private var showRelated + + @Default(.captionsAutoShow) private var captionsAutoShow + @Default(.captionsDefaultLanguageCode) private var captionsDefaultLanguageCode + @Default(.captionsFallbackLanguageCode) private var captionsFallbackLanguageCode + @Default(.captionsFontScaleSize) private var captionsFontScaleSize + @Default(.captionsFontColor) private var captionsFontColor @ObservedObject private var accounts = AccountsModel.shared @@ -45,6 +52,11 @@ struct PlayerSettings: View { } #endif + #if os(tvOS) + @State private var isShowingDefaultLanguagePicker = false + @State private var isShowingFallbackLanguagePicker = false + #endif + var body: some View { Group { #if os(macOS) @@ -93,7 +105,54 @@ struct PlayerSettings: View { inspectorVisibilityPicker #endif } + #endif + Section(header: SettingsHeader(text: "Captions".localized())) { + #if os(tvOS) + Text("Size").font(.subheadline) + #endif + captionsFontScaleSizePicker + #if os(tvOS) + Text("Color").font(.subheadline) + #endif + captionsFontColorPicker + showCaptionsAutoShowToggle + + #if !os(tvOS) + captionDefaultLanguagePicker + captionFallbackLanguagePicker + #else + Button(action: { isShowingDefaultLanguagePicker = true }) { + HStack { + Text("Default language") + Spacer() + Text("\(LanguageCodes(rawValue: captionsDefaultLanguageCode)!.description.capitalized) (\(captionsDefaultLanguageCode))").foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity).sheet(isPresented: $isShowingDefaultLanguagePicker) { + defaultLanguagePickerTVOS( + selectedLanguage: $captionsDefaultLanguageCode, + isShowing: $isShowingDefaultLanguagePicker + ) + } + + Button(action: { isShowingFallbackLanguagePicker = true }) { + HStack { + Text("Fallback language") + Spacer() + Text("\(LanguageCodes(rawValue: captionsFallbackLanguageCode)!.description.capitalized) (\(captionsFallbackLanguageCode))").foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity).sheet(isPresented: $isShowingDefaultLanguagePicker) { + fallbackLanguagePickerTVOS( + selectedLanguage: $captionsFallbackLanguageCode, + isShowing: $isShowingFallbackLanguagePicker + ) + } + #endif + } + + #if !os(tvOS) Section(header: SettingsHeader(text: "Chapters".localized())) { showChaptersToggle showThumbnailsToggle @@ -279,6 +338,103 @@ struct PlayerSettings: View { } #endif + private var showCaptionsAutoShowToggle: some View { + Toggle("Always show captions", isOn: $captionsAutoShow) + } + + private var captionsFontScaleSizePicker: some View { + Picker("Size", selection: $captionsFontScaleSize) { + Text("Small").tag(String("0.5")) + Text("Medium").tag(String("1.0")) + Text("Large").tag(String("2.0")) + } + .onChange(of: captionsFontScaleSize) { _ in + PlayerModel.shared.mpvBackend.client.setSubFontSize(scaleSize: captionsFontScaleSize) + } + #if os(macOS) + .labelsHidden() + #endif + } + + private var captionsFontColorPicker: some View { + Picker("Color", selection: $captionsFontColor) { + Text("White").tag(String("#FFFFFF")) + Text("Yellow").tag(String("#FFFF00")) + Text("Red").tag(String("#FF0000")) + Text("Orange").tag(String("#FFA500")) + Text("Green").tag(String("#008000")) + Text("Blue").tag(String("#0000FF")) + } + .onChange(of: captionsFontColor) { _ in + PlayerModel.shared.mpvBackend.client.setSubFontColor(color: captionsFontColor) + } + #if os(macOS) + .labelsHidden() + #endif + } + + #if !os(tvOS) + private var captionDefaultLanguagePicker: some View { + Picker("Default language", selection: $captionsDefaultLanguageCode) { + ForEach(LanguageCodes.allCases, id: \.self) { language in + Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue) + } + } + #if os(macOS) + .labelsHidden() + #endif + } + + private var captionFallbackLanguagePicker: some View { + Picker("Fallback language", selection: $captionsFallbackLanguageCode) { + ForEach(LanguageCodes.allCases, id: \.self) { language in + Text("\(language.description.capitalized) (\(language.rawValue))").tag(language.rawValue) + } + } + #if os(macOS) + .labelsHidden() + #endif + } + #else + struct defaultLanguagePickerTVOS: View { + @Binding var selectedLanguage: String + @Binding var isShowing: Bool + + var body: some View { + NavigationView { + List(LanguageCodes.allCases, id: \.self) { language in + Button(action: { + selectedLanguage = language.rawValue + isShowing = false + }) { + Text("\(language.description.capitalized) (\(language.rawValue))") + } + } + .navigationTitle("Select Default Language") + } + } + } + + struct fallbackLanguagePickerTVOS: View { + @Binding var selectedLanguage: String + @Binding var isShowing: Bool + + var body: some View { + NavigationView { + List(LanguageCodes.allCases, id: \.self) { language in + Button(action: { + selectedLanguage = language.rawValue + isShowing = false + }) { + Text("\(language.description.capitalized) (\(language.rawValue))") + } + } + .navigationTitle("Select Fallback Language") + } + } + } + #endif + #if !os(tvOS) private var inspectorVisibilityPicker: some View { Picker("Inspector", selection: $showInspector) { diff --git a/Shared/YatteeApp.swift b/Shared/YatteeApp.swift index 81458521..88e3a259 100644 --- a/Shared/YatteeApp.swift +++ b/Shared/YatteeApp.swift @@ -154,7 +154,12 @@ struct YatteeApp: App { #if DEBUG SiestaLog.Category.enabled = .common #endif - SDImageCodersManager.shared.addCoder(SDImageAWebPCoder.shared) + #if os(tvOS) + SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) + #else + SDImageCodersManager.shared.addCoder(SDImageAWebPCoder.shared) + #endif + SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app") if !Defaults[.lastAccountIsPublic] { diff --git a/Shared/ar.lproj/Localizable.strings b/Shared/ar.lproj/Localizable.strings index 3d010e2a..019185f9 100644 --- a/Shared/ar.lproj/Localizable.strings +++ b/Shared/ar.lproj/Localizable.strings @@ -387,7 +387,7 @@ "Backend" = "الواجهة الخلفية"; "Badge" = "الشارة"; "Close PiP when starting playing other video" = "غلق الفيديو المصغر عند بدء تشغيل فيديو آخر"; -"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "تذكيرات صريحة لإبداء الإعجاب بها أو الإشتراك فيها أو التفاعل معها على أي منصة (منصات) مدفوعة أو مجانية (مثل النقر فوق مقطع الفيديو).\n"; +"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "تذكيرات صريحة لإبداء الإعجاب بها أو الإشتراك فيها أو التفاعل معها على أي منصة (منصات) مدفوعة أو مجانية (مثل النقر فوق مقطع الفيديو)."; "Filter" = "‏ عامل التصفية"; "Frontend URL" = "عنوان URL للواجهة الأمامية"; "Fullscreen size" = "حجم ملء الشاشة"; @@ -628,4 +628,4 @@ "Password required to import" = "كلمة المرور مطلوبة للإستيراد"; "Password saved in import file" = "كلمة المرور محفوظة في ملف الإستيراد"; "Export in progress..." = "جارِ التصدير..."; -"In progress..." = "في تَقَدم…"; +"In progress..." = "في طور الأجراء…"; diff --git a/Shared/pt-BR.lproj/Localizable.strings b/Shared/pt-BR.lproj/Localizable.strings index 731cdfb1..4e1ecb2d 100644 --- a/Shared/pt-BR.lproj/Localizable.strings +++ b/Shared/pt-BR.lproj/Localizable.strings @@ -406,7 +406,7 @@ "Country" = "País"; "Clear All" = "Limpar Tudo"; "Clear All Recents" = "Limpar Todos os Recentes"; -"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos de dar like, se inscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clicar em um vídeo).\n"; +"Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video)." = "Lembretes explícitos de dar like, se inscrever ou interagir com eles em qualquer plataforma, paga ou grátis (p.ex. clicar em um vídeo)."; "Duration" = "Duração"; "Edit Quality Profile" = "Editar Perfil de Qualidade"; "Discussions take place in Discord and Matrix. It's a good spot for general questions." = "Discussões acontecem no Discord e no Matrix. É um bom lugar para perguntas gerais."; diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 9409e09b..6ec0354f 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -1079,6 +1079,9 @@ E258F38A2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; }; E258F38B2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; }; E258F38C2BF61BD2005B8C28 /* URLTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = E258F3892BF61BD2005B8C28 /* URLTester.swift */; }; + E27568B92BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; }; + E27568BA2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; }; + E27568BB2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */; }; FA97174C2A494700001FF53D /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = FA97174B2A494700001FF53D /* MPVKit */; }; /* End PBXBuildFile section */ @@ -1551,6 +1554,7 @@ E24DC6572BFA124100BF6187 /* UserAgentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentManager.swift; sourceTree = "<group>"; }; E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatus.swift; sourceTree = "<group>"; }; E258F3892BF61BD2005B8C28 /* URLTester.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLTester.swift; sourceTree = "<group>"; }; + E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageCodes.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2299,6 +2303,7 @@ 37D2E0D328B67EFC00F64D52 /* Delay.swift */, 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */, E25028AF2BF790F5002CB9FC /* HTTPStatus.swift */, + E27568B82BFAAC2000BDF0AF /* LanguageCodes.swift */, 375B537828DF6CBB004C1D19 /* Localizable.strings */, 3729037D2739E47400EA99F6 /* MenuCommands.swift */, 37B7958F2771DAE0001CF27B /* OpenURLHandler.swift */, @@ -3231,6 +3236,7 @@ 37C3A24D272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */, 37484C2D26FC844700287258 /* InstanceSettings.swift in Sources */, 37DD9DA32785BBC900539416 /* NoCommentsView.swift in Sources */, + E27568B92BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */, 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */, 377ABC48286E5887009C986F /* Sequence+Unique.swift in Sources */, @@ -3411,6 +3417,7 @@ 371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */, 3756C2AB2861151C00E4B059 /* NetworkStateModel.swift in Sources */, 375EC95A289EEB8200751258 /* QualityProfileForm.swift in Sources */, + E27568BA2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */, 37001564271B1F250049C794 /* AccountsModel.swift in Sources */, 378FFBC528660172009E3FBE /* URLParser.swift in Sources */, 3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */, @@ -4008,6 +4015,7 @@ 37FB28432721B22200A57617 /* ContentItem.swift in Sources */, 37A7D6EF2B67E3BF009CB1ED /* BrowsingSettingsGroupImporter.swift in Sources */, 37D2E0D228B67DBC00F64D52 /* AnimationCompletionObserverModifier.swift in Sources */, + E27568BB2BFAAC2000BDF0AF /* LanguageCodes.swift in Sources */, 37A7D6EB2B67E334009CB1ED /* BrowsingSettingsGroupExporter.swift in Sources */, 37AAF2A226741C97007FC770 /* FeedView.swift in Sources */, 37484C1B26FC837400287258 /* PlayerSettings.swift in Sources */,