diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index 72865cae..9a6ee1a1 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -428,7 +428,8 @@ 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: extractChapters(from: description), + captions: extractCaptions(from: json) ) } @@ -566,4 +567,17 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { channel: Channel(id: channelId, name: author) ) } + + private func extractCaptions(from content: JSON) -> [Captions] { + content["captions"].arrayValue.compactMap { details in + guard let baseURL = account.url, + let url = URL(string: baseURL + details["url"].stringValue) else { return nil } + + return Captions( + label: details["label"].stringValue, + code: details["language_code"].stringValue, + url: url + ) + } + } } diff --git a/Model/Captions.swift b/Model/Captions.swift new file mode 100644 index 00000000..a09a6d28 --- /dev/null +++ b/Model/Captions.swift @@ -0,0 +1,12 @@ +import Foundation + +struct Captions: Hashable, Identifiable { + var id = UUID().uuidString + let label: String + let code: String + let url: URL + + var description: String { + "\(label) (\(code))" + } +} diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 6ec63131..473cc7a5 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -19,6 +19,13 @@ final class MPVBackend: PlayerBackend { var stream: Stream? var video: Video? + var captions: Captions? { didSet { + guard let captions = captions else { + client.removeSubs() + return + } + addSubTrack(captions.url) + }} var currentTime: CMTime? var loadedVideo = false @@ -155,11 +162,18 @@ 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) } + } + let updateCurrentStream = { DispatchQueue.main.async { [weak self] in self?.stream = stream self?.video = video self?.model.stream = stream + self?.captions = captions } } @@ -211,7 +225,7 @@ final class MPVBackend: PlayerBackend { startPlaying() } - self.client.loadFile(url, time: time) { [weak self] _ in + self.client.loadFile(url, sub: captions?.url, time: time) { [weak self] _ in self?.isLoadingVideo = true } } else { @@ -223,7 +237,7 @@ final class MPVBackend: PlayerBackend { let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url - self.client?.loadFile(fileToLoad, audio: audioTrack, time: time) { [weak self] _ in + self.client?.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time) { [weak self] _ in self?.isLoadingVideo = true self?.pause() } @@ -454,6 +468,11 @@ final class MPVBackend: PlayerBackend { client?.addVideoTrack(url) } + func addSubTrack(_ url: URL) { + client?.removeSubs() + client?.addSubTrack(url) + } + func setVideoToAuto() { client?.setVideoToAuto() } diff --git a/Model/Player/Backends/MPVClient.swift b/Model/Player/Backends/MPVClient.swift index ab06b7cb..7a08a98c 100644 --- a/Model/Player/Backends/MPVClient.swift +++ b/Model/Player/Backends/MPVClient.swift @@ -110,7 +110,7 @@ final class MPVClient: ObservableObject { } } - func loadFile(_ url: URL, audio: URL? = nil, time: CMTime? = nil, completionHandler: ((Int32) -> Void)? = nil) { + func loadFile(_ url: URL, audio: URL? = nil, sub: URL? = nil, time: CMTime? = nil, completionHandler: ((Int32) -> Void)? = nil) { var args = [url.absoluteString] var options = [String]() @@ -123,6 +123,10 @@ final class MPVClient: ObservableObject { options.append("audio-files-append=\"\(audioURL)\"") } + if let subURL = sub?.absoluteString { + options.append("sub-files-append=\"\(subURL)\"") + } + args.append(options.joined(separator: ",")) command("loadfile", args: args, returnValueCallback: completionHandler) @@ -263,6 +267,14 @@ final class MPVClient: ObservableObject { command("video-add", args: [url.absoluteString]) } + func addSubTrack(_ url: URL) { + command("sub-add", args: [url.absoluteString]) + } + + func removeSubs() { + command("sub-remove") + } + func setVideoToAuto() { setString("video", "1") } diff --git a/Model/Player/PlayerControlsModel.swift b/Model/Player/PlayerControlsModel.swift index 34ffd1b0..e6779e8b 100644 --- a/Model/Player/PlayerControlsModel.swift +++ b/Model/Player/PlayerControlsModel.swift @@ -47,6 +47,11 @@ final class PlayerControlsModel: ObservableObject { func handleOverlayPresentationChange() { player?.backend.setNeedsNetworkStateUpdates(presentingControlsOverlay) + if presentingControlsOverlay { + removeTimer() + } else { + resetTimer() + } } func show() { diff --git a/Model/Video.swift b/Model/Video.swift index d7bcd37a..497ce4ae 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -34,6 +34,8 @@ struct Video: Identifiable, Equatable, Hashable { var related = [Video]() var chapters = [Chapter]() + var captions = [Captions]() + init( id: String? = nil, videoID: String, @@ -55,7 +57,8 @@ struct Video: Identifiable, Equatable, Hashable { keywords: [String] = [], streams: [Stream] = [], related: [Video] = [], - chapters: [Chapter] = [] + chapters: [Chapter] = [], + captions: [Captions] = [] ) { self.id = id ?? UUID().uuidString self.videoID = videoID @@ -78,6 +81,7 @@ struct Video: Identifiable, Equatable, Hashable { self.streams = streams self.related = related self.chapters = chapters + self.captions = captions } var publishedDate: String? { diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 178e792a..f185f6b9 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -48,6 +48,7 @@ extension Defaults.Keys { static let roundedThumbnails = Key("roundedThumbnails", default: true) static let thumbnailsQuality = Key("thumbnailsQuality", default: .highest) + static let captionsLanguageCode = Key("captionsLanguageCode") static let activeBackend = Key("activeBackend", default: .mpv) static let quality = Key("quality", default: .best) static let playerSidebar = Key("playerSidebar", default: PlayerSidebarSetting.defaultValue) diff --git a/Shared/Player/Controls/ControlsOverlay.swift b/Shared/Player/Controls/ControlsOverlay.swift index 0937ee92..589b68f4 100644 --- a/Shared/Player/Controls/ControlsOverlay.swift +++ b/Shared/Player/Controls/ControlsOverlay.swift @@ -9,24 +9,27 @@ struct ControlsOverlay: View { @Default(.showMPVPlaybackStats) private var showMPVPlaybackStats var body: some View { - VStack(spacing: 6) { - HStack { - backendButtons - } - qualityButton - HStack { - decreaseRateButton - rateButton - increaseRateButton - } - #if os(iOS) - .foregroundColor(.white) - #endif + ScrollView { + VStack(spacing: 6) { + HStack { + backendButtons + } + qualityButton + captionsButton + HStack { + decreaseRateButton + rateButton + increaseRateButton + } + #if os(iOS) + .foregroundColor(.white) + #endif - if player.activeBackend == .mpv, - showMPVPlaybackStats - { - mpvPlaybackStats + if player.activeBackend == .mpv, + showMPVPlaybackStats + { + mpvPlaybackStats + } } } } @@ -128,6 +131,60 @@ struct ControlsOverlay: View { #endif } + @ViewBuilder private var captionsButton: some View { + #if os(macOS) + captionsPicker + .labelsHidden() + .frame(maxWidth: 300) + #else + Menu { + captionsPicker + .frame(width: 140, height: 30) + .mask(RoundedRectangle(cornerRadius: 3)) + } label: { + HStack(spacing: 4) { + Image(systemName: "text.bubble") + if let captions = captionsBinding.wrappedValue { + Text(captions.code) + .foregroundColor(.primary) + } + } + .frame(width: 140, height: 30) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(width: 140, height: 30) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) + #endif + } + + @ViewBuilder private var captionsPicker: some View { + let captions = player.currentVideo?.captions ?? [] + Picker("Captions", selection: captionsBinding) { + if captions.isEmpty { + Text("Not available") + } else { + Text("Disabled").tag(Captions?.none) + } + ForEach(captions) { caption in + Text(caption.description).tag(Optional(caption)) + } + } + .disabled(captions.isEmpty) + } + + private var captionsBinding: Binding { + .init( + get: { player.mpvBackend.captions }, + set: { + player.mpvBackend.captions = $0 + Defaults[.captionsLanguageCode] = $0?.code + } + ) + } + @ViewBuilder private var rateButton: some View { #if os(macOS) ratePicker @@ -185,6 +242,7 @@ struct ControlsOverlay: View { struct ControlsOverlay_Previews: PreviewProvider { static var previews: some View { ControlsOverlay() + .environmentObject(NetworkStateModel()) .environmentObject(PlayerModel()) .environmentObject(PlayerControlsModel()) } diff --git a/Shared/Player/Controls/PlayerControls.swift b/Shared/Player/Controls/PlayerControls.swift index c996a1a8..4f5ffdde 100644 --- a/Shared/Player/Controls/PlayerControls.swift +++ b/Shared/Player/Controls/PlayerControls.swift @@ -97,6 +97,7 @@ struct PlayerControls: View { #endif ControlsOverlay() + .frame(height: overlayHeight) .padding() .modifier(ControlBackgroundModifier(enabled: true)) .clipShape(RoundedRectangle(cornerRadius: 4)) @@ -127,6 +128,11 @@ struct PlayerControls: View { } } + var overlayHeight: Double { + guard let player = player, player.playerSize.height.isFinite else { return 0 } + return [0, [player.playerSize.height - 80, 140].min()!].max()! + } + @ViewBuilder var controlsBackground: some View { if player.musicMode, let item = self.player.currentItem, diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index 8406388b..44cf8aed 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -199,7 +199,8 @@ struct VideoPlayerView: View { .gesture( DragGesture(coordinateSpace: .global) .onChanged { value in - guard player.presentingPlayer else { return } + guard player.presentingPlayer, + !playerControls.presentingControlsOverlay else { return } let drag = value.translation.height diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 8ddde2ff..648fa4cd 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -397,7 +397,6 @@ 37732FF52703D32400F04329 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37732FF32703D32400F04329 /* Sidebar.swift */; }; 37737786276F9858000521C1 /* Windows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37737785276F9858000521C1 /* Windows.swift */; }; 3774123327387CB000423605 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; }; - 3774123427387CC100423605 /* InvidiousAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37977582268922F600DD52A8 /* InvidiousAPI.swift */; }; 3774124927387D2300423605 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AAF28F26740715007FC770 /* Channel.swift */; }; 3774124A27387D2300423605 /* ContentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB28402721B22200A57617 /* ContentItem.swift */; }; 3774124B27387D2300423605 /* ThumbnailsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C0698127260B2100F7F6CB /* ThumbnailsModel.swift */; }; @@ -438,6 +437,9 @@ 3774127827387EB000423605 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 3774127727387EB000423605 /* Logging */; }; 3774127A27387EBC00423605 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 3774127927387EBC00423605 /* Defaults */; }; 3774127C27387EC800423605 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 3774127B27387EC800423605 /* Alamofire */; }; + 3776ADD6287381240078EBC4 /* Captions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776ADD5287381240078EBC4 /* Captions.swift */; }; + 3776ADD7287381240078EBC4 /* Captions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776ADD5287381240078EBC4 /* Captions.swift */; }; + 3776ADD8287381240078EBC4 /* Captions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776ADD5287381240078EBC4 /* Captions.swift */; }; 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */; }; 377A20AA2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */; }; 377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */; }; @@ -1048,6 +1050,7 @@ 37732FEF2703A26300F04329 /* AccountValidationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountValidationStatus.swift; sourceTree = ""; }; 37732FF32703D32400F04329 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; 37737785276F9858000521C1 /* Windows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windows.swift; sourceTree = ""; }; + 3776ADD5287381240078EBC4 /* Captions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Captions.swift; path = Model/Captions.swift; sourceTree = SOURCE_ROOT; }; 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedContentAccessors.swift; sourceTree = ""; }; 377ABC3F286E4AD5009C986F /* InstancesManifest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstancesManifest.swift; sourceTree = ""; }; 377ABC43286E4B74009C986F /* ManifestedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestedInstance.swift; sourceTree = ""; }; @@ -1964,6 +1967,7 @@ 3751BA8127E69131007B1A60 /* ReturnYouTubeDislike */, 37FB283F2721B20800A57617 /* Search */, 374C0539272436DA009BDDBE /* SponsorBlock */, + 3776ADD5287381240078EBC4 /* Captions.swift */, 37AAF28F26740715007FC770 /* Channel.swift */, 37C3A24427235DA70087A57A /* ChannelPlaylist.swift */, 37520698285E8DD300CA655F /* Chapter.swift */, @@ -2672,6 +2676,7 @@ 37130A5F277657300033018A /* PersistenceController.swift in Sources */, 37FD43E32704847C0073EE42 /* View+Fixtures.swift in Sources */, 37BE0BD626A1D4A90092E2DB /* AppleAVPlayerViewController.swift in Sources */, + 3776ADD6287381240078EBC4 /* Captions.swift in Sources */, 37BA793F26DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 37C194C726F6A9C8005D3B96 /* RecentsModel.swift in Sources */, 37484C1926FC837400287258 /* PlayerSettings.swift in Sources */, @@ -2928,6 +2933,7 @@ 3782B9532755667600990149 /* String+Format.swift in Sources */, 37E2EEAC270656EC00170416 /* BrowserPlayerControls.swift in Sources */, 37BF662027308884008CCFB0 /* DropFavoriteOutside.swift in Sources */, + 3776ADD7287381240078EBC4 /* Captions.swift in Sources */, 37E70924271CD43000D34DDE /* WelcomeScreen.swift in Sources */, 374C0543272496E4009BDDBE /* AppDelegate.swift in Sources */, 373CFACC26966264003CB2C6 /* SearchQuery.swift in Sources */, @@ -3057,7 +3063,6 @@ 3774125627387D2300423605 /* Segment.swift in Sources */, 373C8FE7275B955100CB5936 /* CommentsPage.swift in Sources */, 3774124D27387D2300423605 /* PlaylistsModel.swift in Sources */, - 3774123427387CC100423605 /* InvidiousAPI.swift in Sources */, 3774124B27387D2300423605 /* ThumbnailsModel.swift in Sources */, 3774125427387D2300423605 /* Store.swift in Sources */, 3774125027387D2300423605 /* Video.swift in Sources */, @@ -3119,6 +3124,7 @@ 371B7E632759706A00D21217 /* CommentsView.swift in Sources */, 37A9965C26D6F8CA006E3224 /* HorizontalCells.swift in Sources */, 3782B95727557E6E00990149 /* SearchSuggestions.swift in Sources */, + 3776ADD8287381240078EBC4 /* Captions.swift in Sources */, 37F0F4EC286F397E00C06C2E /* SettingsModel.swift in Sources */, 37BD07C92698FBDB003EBB87 /* ContentView.swift in Sources */, 37BC50AA2778A84700510953 /* HistorySettings.swift in Sources */,