From 59ce59da523abb1bfca493b7883a01bc0a3f297b Mon Sep 17 00:00:00 2001 From: Eric Andrews Date: Tue, 10 Dec 2024 22:41:17 -0500 Subject: [PATCH] Audio for Videos (#1490) Co-authored-by: Sjmarf <78750526+Sjmarf@users.noreply.github.com> --- Mlem.xcodeproj/project.pbxproj | 12 +++ Mlem/App/Configuration/Icons.swift | 6 +- .../User Settings/CodableSettings.swift | 3 + .../User Settings/Settings.swift | 3 + .../Extensions/AVPlayer+Extensions.swift | 15 ++++ Mlem/App/Views/Root/MlemApp.swift | 9 +++ .../Tabs/Settings/GeneralSettingsView.swift | 2 + .../Shared/Images/Core/Animated/GifView.swift | 19 +++-- .../Images/Core/Animated/VideoView.swift | 81 ++++++++++++++++--- .../Images/Core/Animated/WebpView.swift | 21 +++++ .../Shared/Images/Core/DynamicMediaView.swift | 26 +++--- .../Views/Shared/Images/Core/MediaView.swift | 4 +- .../Helpers/AnimationControlLayer.swift | 53 ++++++++++++ Mlem/Localizable.xcstrings | 5 +- 14 files changed, 225 insertions(+), 34 deletions(-) create mode 100644 Mlem/App/Utility/Extensions/AVPlayer+Extensions.swift create mode 100644 Mlem/App/Views/Shared/Images/Core/Animated/WebpView.swift create mode 100644 Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift diff --git a/Mlem.xcodeproj/project.pbxproj b/Mlem.xcodeproj/project.pbxproj index 48484e0d1..cce2b8530 100644 --- a/Mlem.xcodeproj/project.pbxproj +++ b/Mlem.xcodeproj/project.pbxproj @@ -331,6 +331,8 @@ CD4ED8472BF110FA00EFA0A2 /* TabReselectTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4ED8462BF110FA00EFA0A2 /* TabReselectTracker.swift */; }; CD4ED84A2BF1113800EFA0A2 /* View+TabReselectConsumer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4ED8492BF1113800EFA0A2 /* View+TabReselectConsumer.swift */; }; CD5581DE2C7B8B820043FAC3 /* ImageFunctions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5581DD2C7B8B820043FAC3 /* ImageFunctions.swift */; }; + CD57AFA32D03761500AB3956 /* WebpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD57AFA22D03761300AB3956 /* WebpView.swift */; }; + CD57AFA52D0377EB00AB3956 /* AnimationControlLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD57AFA42D0377E400AB3956 /* AnimationControlLayer.swift */; }; CD5F3F452D06167F008C55D5 /* MlemMiddleware in Frameworks */ = {isa = PBXBuildFile; productRef = CD5F3F442D06167F008C55D5 /* MlemMiddleware */; }; CD635E1B2C94DACD00864F75 /* BypassProxyWarningSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD635E1A2C94DACD00864F75 /* BypassProxyWarningSheet.swift */; }; CD64A91A2CA37E8D007CA7E6 /* Gifu in Frameworks */ = {isa = PBXBuildFile; productRef = CD64A9192CA37E8D007CA7E6 /* Gifu */; }; @@ -347,6 +349,7 @@ CD7DB9712C49C17200DCC542 /* PersonContentGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7DB9702C49C17200DCC542 /* PersonContentGridView.swift */; }; CD7DB9732C4AEDDE00DCC542 /* TileCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7DB9722C4AEDDE00DCC542 /* TileCommentView.swift */; }; CD7DB9762C4D6C0A00DCC542 /* FeedCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD7DB9752C4D6C0A00DCC542 /* FeedCommentView.swift */; }; + CD8457142D07576A00CEA2B8 /* AVPlayer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8457132D07576700CEA2B8 /* AVPlayer+Extensions.swift */; }; CD869FCC2C15F8AC00FC8B5B /* BubblePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD869FCB2C15F8AC00FC8B5B /* BubblePickerView.swift */; }; CD869FCE2C15F90C00FC8B5B /* ChildSizeReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD869FCD2C15F90C00FC8B5B /* ChildSizeReader.swift */; }; CD8DCAF92C5E92E8003E4DD7 /* Profile1Providing+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD8DCAF82C5E92E8003E4DD7 /* Profile1Providing+Extensions.swift */; }; @@ -747,6 +750,8 @@ CD4ED8462BF110FA00EFA0A2 /* TabReselectTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabReselectTracker.swift; sourceTree = ""; }; CD4ED8492BF1113800EFA0A2 /* View+TabReselectConsumer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+TabReselectConsumer.swift"; sourceTree = ""; }; CD5581DD2C7B8B820043FAC3 /* ImageFunctions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFunctions.swift; sourceTree = ""; }; + CD57AFA22D03761300AB3956 /* WebpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebpView.swift; sourceTree = ""; }; + CD57AFA42D0377E400AB3956 /* AnimationControlLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationControlLayer.swift; sourceTree = ""; }; CD635E1A2C94DACD00864F75 /* BypassProxyWarningSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BypassProxyWarningSheet.swift; sourceTree = ""; }; CD64A91B2CA38DA7007CA7E6 /* NukeWebpBridgeDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeWebpBridgeDecoder.swift; sourceTree = ""; }; CD64A91D2CA6255A007CA7E6 /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; @@ -761,6 +766,7 @@ CD7DB9702C49C17200DCC542 /* PersonContentGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonContentGridView.swift; sourceTree = ""; }; CD7DB9722C4AEDDE00DCC542 /* TileCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileCommentView.swift; sourceTree = ""; }; CD7DB9752C4D6C0A00DCC542 /* FeedCommentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCommentView.swift; sourceTree = ""; }; + CD8457132D07576700CEA2B8 /* AVPlayer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVPlayer+Extensions.swift"; sourceTree = ""; }; CD869FCB2C15F8AC00FC8B5B /* BubblePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubblePickerView.swift; sourceTree = ""; }; CD869FCD2C15F90C00FC8B5B /* ChildSizeReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildSizeReader.swift; sourceTree = ""; }; CD869FCF2C15F92E00FC8B5B /* Int+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Extensions.swift"; sourceTree = ""; }; @@ -1341,6 +1347,7 @@ CD13CC662C5D3CA7001AF428 /* Helpers */ = { isa = PBXGroup; children = ( + CD57AFA42D0377E400AB3956 /* AnimationControlLayer.swift */, CD332D782CA7175200A53988 /* PlayButton.swift */, CD64A91B2CA38DA7007CA7E6 /* NukeWebpBridgeDecoder.swift */, CD13CC602C5D262E001AF428 /* MediaLoadingState.swift */, @@ -1409,6 +1416,7 @@ CD332D7A2CA71E5D00A53988 /* Animated */ = { isa = PBXGroup; children = ( + CD57AFA22D03761300AB3956 /* WebpView.swift */, CD332D7B2CA71E6E00A53988 /* GifView.swift */, CDE4AC3E2CA2082F00981010 /* VideoView.swift */, ); @@ -1558,6 +1566,7 @@ CD4D58C22B86DC5800B82964 /* Extensions */ = { isa = PBXGroup; children = ( + CD8457132D07576700CEA2B8 /* AVPlayer+Extensions.swift */, CD332D7D2CA7485D00A53988 /* String+Extensions.swift */, 0397D47F2C693A88002C6CDC /* [BlockNode]+Extensions.swift */, 03A82FA02C0D1E8500D01A5C /* ApiClient+Extensions.swift */, @@ -2317,6 +2326,7 @@ CDCA44B42C176A4700C092B3 /* Array+Extensions.swift in Sources */, 03CCDAA42BF2852E00C0C851 /* LoginTotpView.swift in Sources */, CD4ED8472BF110FA00EFA0A2 /* TabReselectTracker.swift in Sources */, + CD8457142D07576A00CEA2B8 /* AVPlayer+Extensions.swift in Sources */, 033FCAEE2C57DD14007B7CD1 /* CaptchaDifficulty+Extensions.swift in Sources */, 035BE0912BDEA01E00F77D73 /* View+NavigationSheetModifiers.swift in Sources */, CDAA02DB2C810DB200D75633 /* Calendar+Extensions.swift in Sources */, @@ -2334,6 +2344,7 @@ CD8DCAF92C5E92E8003E4DD7 /* Profile1Providing+Extensions.swift in Sources */, CDB2EC8A2BFAEFDF00DBC0EF /* ThumbnailImageView.swift in Sources */, 0397D4932C6CE87E002C6CDC /* PostEditorTargetView.swift in Sources */, + CD57AFA32D03761500AB3956 /* WebpView.swift in Sources */, 0369B3562BFA6824001EFEDF /* InboxView.swift in Sources */, CD4D59162B87B38C00B82964 /* UIApplication+Extensions.swift in Sources */, CDB41E8A2C83C24400BD2DE9 /* Section.swift in Sources */, @@ -2385,6 +2396,7 @@ CDC199EA2BE449790077B4F1 /* Interactable1Providing+Extensions.swift in Sources */, 03500C2B2BF7F1B100CAA076 /* ToastOverlayView.swift in Sources */, 032C32042C3439C600595286 /* ActorIdentifiable+Extensions.swift in Sources */, + CD57AFA52D0377EB00AB3956 /* AnimationControlLayer.swift in Sources */, CD4D59182B87B3B000B82964 /* UIViewController+Extensions.swift in Sources */, CD13CC572C582DD8001AF428 /* DynamicMediaView.swift in Sources */, 034B94812C09306D00039AF4 /* CommunityOrPersonStub+Extensions.swift in Sources */, diff --git a/Mlem/App/Configuration/Icons.swift b/Mlem/App/Configuration/Icons.swift index d5b61bd68..03438ee0a 100644 --- a/Mlem/App/Configuration/Icons.swift +++ b/Mlem/App/Configuration/Icons.swift @@ -282,6 +282,11 @@ enum Icons { static let fediseerHesitation: String = "exclamationmark.triangle.fill" static let fediseerCensure: String = "exclamationmark.octagon.fill" + // media + static let play: String = "play.fill" + static let muted: String = "speaker.slash.fill" + static let unmuted: String = "speaker.wave.2.fill" + // misc static let `private`: String = "lock" static let email: String = "envelope" @@ -307,7 +312,6 @@ enum Icons { static let imageDetails: String = "doc.badge.ellipsis" static let accountSwitchReload: String = "arrow.2.circlepath" static let accountSwitchKeepPlace: String = "checkmark.diamond" - static let play: String = "play.fill" } // swiftlint:enable type_body_length diff --git a/Mlem/App/Configuration/User Settings/CodableSettings.swift b/Mlem/App/Configuration/User Settings/CodableSettings.swift index 2954c41f6..0524eede3 100644 --- a/Mlem/App/Configuration/User Settings/CodableSettings.swift +++ b/Mlem/App/Configuration/User Settings/CodableSettings.swift @@ -29,6 +29,7 @@ struct CodableSettings: Codable { var behavior_internetSpeed: InternetSpeed var behavior_upvoteOnSave: Bool var behavior_autoplayMedia: Bool + var behavior_muteVideos: Bool var behavior_infiniteScroll: Bool var comment_behaviors_collapseChildren: Bool var comment_compact: Bool @@ -104,6 +105,7 @@ struct CodableSettings: Codable { self.behavior_hapticLevel = try container.decodeIfPresent(HapticPriority.self, forKey: .behavior_hapticLevel) ?? .high self.behavior_internetSpeed = try container.decodeIfPresent(InternetSpeed.self, forKey: .behavior_internetSpeed) ?? .fast self.behavior_autoplayMedia = try container.decodeIfPresent(Bool.self, forKey: .behavior_autoplayMedia) ?? false + self.behavior_muteVideos = try container.decodeIfPresent(Bool.self, forKey: .behavior_muteVideos) ?? true self.behavior_upvoteOnSave = try container.decodeIfPresent(Bool.self, forKey: .behavior_upvoteOnSave) ?? false self.behavior_infiniteScroll = try container.decodeIfPresent(Bool.self, forKey: .behavior_infiniteScroll) ?? true self.comment_behaviors_collapseChildren = try container.decodeIfPresent(Bool.self, forKey: .comment_behaviors_collapseChildren) ?? false @@ -182,6 +184,7 @@ struct CodableSettings: Codable { self.behavior_internetSpeed = settings.internetSpeed self.behavior_upvoteOnSave = settings.upvoteOnSave self.behavior_autoplayMedia = settings.autoplayMedia + self.behavior_muteVideos = settings.muteVideos self.behavior_infiniteScroll = settings.infiniteScroll self.comment_behaviors_collapseChildren = false self.comment_compact = settings.compactComments diff --git a/Mlem/App/Configuration/User Settings/Settings.swift b/Mlem/App/Configuration/User Settings/Settings.swift index 16db182a3..3afd05d70 100644 --- a/Mlem/App/Configuration/User Settings/Settings.swift +++ b/Mlem/App/Configuration/User Settings/Settings.swift @@ -30,6 +30,7 @@ class Settings: ObservableObject { @AppStorage("behavior.upvoteOnSave") var upvoteOnSave: Bool = false @AppStorage("behavior.internetSpeed") var internetSpeed: InternetSpeed = .fast @AppStorage("behavior.autoplayMedia") var autoplayMedia: Bool = false + @AppStorage("behavior.muteVideos") var muteVideos: Bool = true @AppStorage("behavior.confirmImageUploads") var confirmImageUploads: Bool = true @AppStorage("behavior.infiniteScroll") var infiniteScroll: Bool = true @@ -114,6 +115,8 @@ class Settings: ObservableObject { hapticLevel = settings.behavior_hapticLevel upvoteOnSave = settings.behavior_upvoteOnSave internetSpeed = settings.behavior_internetSpeed + autoplayMedia = settings.behavior_autoplayMedia + muteVideos = settings.behavior_muteVideos infiniteScroll = settings.behavior_infiniteScroll keepPlaceOnAccountSwitch = settings.accounts_keepPlace accountSort = settings.accounts_sort diff --git a/Mlem/App/Utility/Extensions/AVPlayer+Extensions.swift b/Mlem/App/Utility/Extensions/AVPlayer+Extensions.swift new file mode 100644 index 000000000..748156d7c --- /dev/null +++ b/Mlem/App/Utility/Extensions/AVPlayer+Extensions.swift @@ -0,0 +1,15 @@ +// +// AVPlayer+Extensions.swift +// Mlem +// +// Created by Eric Andrews on 2024-12-09. +// +// From https://stackoverflow.com/questions/11704322/how-to-check-if-avplayer-has-video-or-just-audio + +import AVFoundation + +extension AVPlayer { + func isAudioAvailable() async throws -> Bool? { + return try await self.currentItem?.asset.loadTracks(withMediaType: .audio).count != 0 + } +} diff --git a/Mlem/App/Views/Root/MlemApp.swift b/Mlem/App/Views/Root/MlemApp.swift index be307888c..5d413569e 100644 --- a/Mlem/App/Views/Root/MlemApp.swift +++ b/Mlem/App/Views/Root/MlemApp.swift @@ -8,6 +8,7 @@ import Nuke import SDWebImageWebPCoder import SwiftUI +import AVFAudio /// Root view for the app @main @@ -28,7 +29,15 @@ struct MlemApp: App { ImageDecoderRegistry.shared.register(NukeWebpBridgeDecoder.init) SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) + // caching URLCache.shared = Constants.main.urlCache + + // set up audio + do { + try AVAudioSession.sharedInstance().setCategory(.playback) + } catch { + handleError(error) + } } var body: some Scene { diff --git a/Mlem/App/Views/Root/Tabs/Settings/GeneralSettingsView.swift b/Mlem/App/Views/Root/Tabs/Settings/GeneralSettingsView.swift index d0fd143fc..568fa830d 100644 --- a/Mlem/App/Views/Root/Tabs/Settings/GeneralSettingsView.swift +++ b/Mlem/App/Views/Root/Tabs/Settings/GeneralSettingsView.swift @@ -22,6 +22,7 @@ struct GeneralSettingsView: View { @Setting(\.markReadOnScroll) var markReadOnScroll @Setting(\.infiniteScroll) var infiniteScroll @Setting(\.autoplayMedia) var autoplayMedia + @Setting(\.muteVideos) var muteVideos @Setting(\.defaultFeed) var defaultFeed @Setting(\.sidebarVisibleByDefault) var sidebarVisibleByDefault @Setting(\.confirmImageUploads) var confirmImageUploads @@ -65,6 +66,7 @@ struct GeneralSettingsView: View { if #available(iOS 18.0, *) { Toggle("Autoplay Media", isOn: $autoplayMedia) } + Toggle("Mute Videos", isOn: $muteVideos) Toggle("Mark Read on Scroll", isOn: $markReadOnScroll) Toggle("Infinite Scroll", isOn: $infiniteScroll) Toggle("Upvote on Save", isOn: $upvoteOnSave) diff --git a/Mlem/App/Views/Shared/Images/Core/Animated/GifView.swift b/Mlem/App/Views/Shared/Images/Core/Animated/GifView.swift index 59799ff79..e2a8dc2a1 100644 --- a/Mlem/App/Views/Shared/Images/Core/Animated/GifView.swift +++ b/Mlem/App/Views/Shared/Images/Core/Animated/GifView.swift @@ -10,20 +10,19 @@ import SwiftUI struct GifView: View { let data: Data + @State var animating: Bool = true var body: some View { - UIGifView(data: data) - .allowsHitTesting(false) - .overlay { - Color.clear.contentShape(.rect) - } + UIGifView(data: data, animating: animating) + .withAnimationControls(animating: $animating) } } private struct UIGifView: UIViewRepresentable { let data: Data + let animating: Bool - func makeUIView(context: Context) -> some UIView { + func makeUIView(context: Context) -> GIFImageView { let imageView = GIFImageView() imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) @@ -31,7 +30,11 @@ private struct UIGifView: UIViewRepresentable { return imageView } - func updateUIView(_ uiView: UIViewType, context: Context) { - // noop + func updateUIView(_ uiView: GIFImageView, context: Context) { + if animating { + uiView.startAnimatingGIF() + } else { + uiView.stopAnimatingGIF() + } } } diff --git a/Mlem/App/Views/Shared/Images/Core/Animated/VideoView.swift b/Mlem/App/Views/Shared/Images/Core/Animated/VideoView.swift index 4cf26d8a1..ae7bdcbd4 100644 --- a/Mlem/App/Views/Shared/Images/Core/Animated/VideoView.swift +++ b/Mlem/App/Views/Shared/Images/Core/Animated/VideoView.swift @@ -8,19 +8,80 @@ import AVFoundation import NukeVideo import SwiftUI +import AVKit -struct VideoView: UIViewRepresentable { - let asset: AVAsset +struct VideoView: View { + let player: AVQueuePlayer + let playerLooper: AVPlayerLooper - func makeUIView(context: Context) -> some UIView { - let view = VideoPlayerView() - view.asset = asset - view.videoGravity = .resizeAspect - view.play() - return view + /// Controls the video's animation (true for playing). Defaults to false; video is automatically started once audio is resolved + @State var animating: Bool = false + + /// Controls the video's audio state (true for muted). + @State var muted: Bool + + /// Whether the video has an audio track. Set post-appearance since this is asynchronously computed. + @State var audioAvailable: Bool = false + + /// Observer to track external modifications to the `isMuted` status of the player. + @State var observer: NSKeyValueObservation? + + /// Whether this is the first time this view has appeared + @State var isFirstAppearance: Bool = true + + init(asset: AVAsset) { + // set up AVQueuePlayer and AVPlayerLooper to loop the video + let playerItem: AVPlayerItem = .init(asset: asset) + player = .init(playerItem: playerItem) + playerLooper = .init(player: player, templateItem: playerItem) + + @Setting(\.muteVideos) var muteVideos + player.isMuted = muteVideos + self._muted = .init(wrappedValue: muteVideos) } - func updateUIView(_ uiView: UIViewType, context: Context) { - // noop + var body: some View { + VideoPlayer(player: player) + .disabled(true) + .onDisappear { + observer = nil + } + .onAppear { + guard observer == nil else { return } + + // audio is automatically turned on if the user modifies their volume. This listens to that event and updates muted to match. + observer = player.observe(\.isMuted, options: [.new]) { _, value in + if let newValue = value.newValue, newValue != muted { + muted = newValue + } + } + } + .task { + // parse whether the video has audio or not before playing so we can appropriately display audio controls + do { + audioAvailable = try await player.isAudioAvailable() ?? false + } catch { + handleError(error) + } + + // if parse fails, assume no audio and play anyway + if isFirstAppearance { + animating = true + isFirstAppearance = false + } + } + .onChange(of: animating, initial: false) { + if animating { + player.play() + } else { + player.pause() + } + } + .onChange(of: muted, initial: false) { + if player.isMuted != muted { + player.isMuted = muted + } + } + .withAnimationControls(animating: $animating, muted: audioAvailable ? $muted : nil) } } diff --git a/Mlem/App/Views/Shared/Images/Core/Animated/WebpView.swift b/Mlem/App/Views/Shared/Images/Core/Animated/WebpView.swift new file mode 100644 index 000000000..90c08661e --- /dev/null +++ b/Mlem/App/Views/Shared/Images/Core/Animated/WebpView.swift @@ -0,0 +1,21 @@ +// +// WebpView.swift +// Mlem +// +// Created by Eric Andrews on 2024-12-06. +// + +import SDWebImageSwiftUI +import SwiftUI + +struct WebpView: View { + let data: Data + + @State var animating: Bool = true + + var body: some View { + AnimatedImage(data: data, isAnimating: $animating) + .resizable() + .withAnimationControls(animating: $animating) + } +} diff --git a/Mlem/App/Views/Shared/Images/Core/DynamicMediaView.swift b/Mlem/App/Views/Shared/Images/Core/DynamicMediaView.swift index 0d04b0bec..a99ccea2d 100644 --- a/Mlem/App/Views/Shared/Images/Core/DynamicMediaView.swift +++ b/Mlem/App/Views/Shared/Images/Core/DynamicMediaView.swift @@ -48,10 +48,16 @@ struct DynamicMediaView: View { } var body: some View { - if #available(iOS 18.0, *) { - ios18Body() - } else { - legacyBody + Group { + if #available(iOS 18.0, *) { + ios18Body() + } else { + legacyBody + } + } + .onDisappear { + // TODO: iOS 17 deprecation remove this--redundant with onScrollVisibilityChange handler + playing = false } } @@ -59,9 +65,12 @@ struct DynamicMediaView: View { func ios18Body() -> some View { legacyBody .onScrollVisibilityChange(threshold: 0.5) { isVisible in - if autoplayMedia { + if isVisible, autoplayMedia { playing = isVisible } + if !isVisible { + playing = false + } } } @@ -92,12 +101,7 @@ struct DynamicMediaView: View { if showError, loader.error != nil { errorOverlay } else if loader.mediaType.isAnimated { - if playing { - Color.clear.contentShape(.rect) - .onTapGesture { - playing = false - } - } else { + if !playing { PlayButton(postSize: .large) .onTapGesture { playing = true diff --git a/Mlem/App/Views/Shared/Images/Core/MediaView.swift b/Mlem/App/Views/Shared/Images/Core/MediaView.swift index 43df0672c..2c7a3005d 100644 --- a/Mlem/App/Views/Shared/Images/Core/MediaView.swift +++ b/Mlem/App/Views/Shared/Images/Core/MediaView.swift @@ -19,7 +19,6 @@ struct MediaView: View { image .overlay { // overlay to prevent visual hitch when swapping views and to implicitly preserve frame/cropping - // TODO: tap should play/pause if playing { animatedContent .background { @@ -46,8 +45,7 @@ struct MediaView: View { case let .gif(_, animated): GifView(data: animated) case let .webp(_, animated): - AnimatedImage(data: animated) - .resizable() + WebpView(data: animated) default: EmptyView() } diff --git a/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift b/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift new file mode 100644 index 000000000..d948d1776 --- /dev/null +++ b/Mlem/App/Views/Shared/Images/Helpers/AnimationControlLayer.swift @@ -0,0 +1,53 @@ +// +// AnimationControlLayer.swift +// Mlem +// +// Created by Eric Andrews on 2024-12-06. +// + +import SwiftUICore + +private struct AnimationControlLayer: ViewModifier { + @Binding var animating: Bool + var muted: Binding? + + func body(content: Content) -> some View { + content + .overlay { + if animating { + Color.clear.contentShape(.rect) + .onTapGesture { + animating = false + } + } else { + PlayButton(postSize: .large) + .onTapGesture { + animating = true + } + } + } + .overlay(alignment: .topTrailing) { + if let muted { + Image(systemName: muted.wrappedValue ? Icons.muted : Icons.unmuted) + .resizable() + .scaledToFit() + .frame(width: 15, height: 15) + .padding(5) + .background(.ultraThinMaterial, in: .circle) + .foregroundStyle(.white) + .padding([.top, .trailing], 5) + .padding([.bottom, .leading], 15) + .contentShape(.rect) + .onTapGesture { + muted.wrappedValue = !muted.wrappedValue + } + } + } + } +} + +extension View { + func withAnimationControls(animating: Binding, muted: Binding? = nil) -> some View { + modifier(AnimationControlLayer(animating: animating, muted: muted)) + } +} diff --git a/Mlem/Localizable.xcstrings b/Mlem/Localizable.xcstrings index 464ec195c..4586fab6a 100644 --- a/Mlem/Localizable.xcstrings +++ b/Mlem/Localizable.xcstrings @@ -936,6 +936,9 @@ }, "Most Comments" : { + }, + "Mute Videos" : { + }, "My Profile" : { @@ -1871,4 +1874,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +}