diff --git a/Sora.xcodeproj/project.pbxproj b/Sora.xcodeproj/project.pbxproj index 658f976..755cd34 100644 --- a/Sora.xcodeproj/project.pbxproj +++ b/Sora.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 1308CFBC2D19844A004CD38C /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1308CFBB2D19844A004CD38C /* Double+Extension.swift */; }; + 1308CFBE2D19844D004CD38C /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1308CFBD2D19844D004CD38C /* MusicProgressSlider.swift */; }; + 1308CFC12D198466004CD38C /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1308CFC02D198466004CD38C /* CustomPlayer.swift */; }; 132417842D13198000B4F2D2 /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417832D13198000B4F2D2 /* SoraApp.swift */; }; 132417862D13198000B4F2D2 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417852D13198000B4F2D2 /* ContentView.swift */; }; 132417882D13198200B4F2D2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 132417872D13198200B4F2D2 /* Assets.xcassets */; }; @@ -39,6 +42,9 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 1308CFBB2D19844A004CD38C /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; + 1308CFBD2D19844D004CD38C /* MusicProgressSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; + 1308CFC02D198466004CD38C /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = ""; }; 132417802D13198000B4F2D2 /* Sora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sora.app; sourceTree = BUILT_PRODUCTS_DIR; }; 132417832D13198000B4F2D2 /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = ""; }; 132417852D13198000B4F2D2 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -83,6 +89,24 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1308CFBA2D19843E004CD38C /* CustomPlayer */ = { + isa = PBXGroup; + children = ( + 1308CFC02D198466004CD38C /* CustomPlayer.swift */, + 1308CFBF2D198450004CD38C /* Components */, + ); + path = CustomPlayer; + sourceTree = ""; + }; + 1308CFBF2D198450004CD38C /* Components */ = { + isa = PBXGroup; + children = ( + 1308CFBD2D19844D004CD38C /* MusicProgressSlider.swift */, + 1308CFBB2D19844A004CD38C /* Double+Extension.swift */, + ); + path = Components; + sourceTree = ""; + }; 132417772D13198000B4F2D2 = { isa = PBXGroup; children = ( @@ -124,6 +148,7 @@ 132417912D1319E800B4F2D2 /* Utils */ = { isa = PBXGroup; children = ( + 1308CFBA2D19843E004CD38C /* CustomPlayer */, 132417922D1319E800B4F2D2 /* Miru */, 132417942D1319E800B4F2D2 /* Extensions */, 132417962D1319E800B4F2D2 /* History */, @@ -328,10 +353,12 @@ 132417BB2D131A0600B4F2D2 /* SettingsIUView.swift in Sources */, 132417C42D131A0600B4F2D2 /* AnimeInfoExtraction.swift in Sources */, 132417B82D131A0600B4F2D2 /* SearchView.swift in Sources */, + 1308CFBC2D19844A004CD38C /* Double+Extension.swift in Sources */, 132417D92D1328B900B4F2D2 /* VideoPlayerView.swift in Sources */, 1324179F2D1319E800B4F2D2 /* Notification.swift in Sources */, 132417BD2D131A0600B4F2D2 /* SettingsModuleView.swift in Sources */, 132417BC2D131A0600B4F2D2 /* SettingsLogsView.swift in Sources */, + 1308CFC12D198466004CD38C /* CustomPlayer.swift in Sources */, 132417A22D1319E800B4F2D2 /* ModulesManager.swift in Sources */, 132417862D13198000B4F2D2 /* ContentView.swift in Sources */, 132417C22D131A0600B4F2D2 /* LibraryView.swift in Sources */, @@ -347,6 +374,7 @@ 132417C12D131A0600B4F2D2 /* LibraryManager.swift in Sources */, 132417BA2D131A0600B4F2D2 /* SettingsAboutView.swift in Sources */, 1324179E2D1319E800B4F2D2 /* MiruDataStruct.swift in Sources */, + 1308CFBE2D19844D004CD38C /* MusicProgressSlider.swift in Sources */, 132417D52D13240200B4F2D2 /* EpisodeCell.swift in Sources */, 132417A02D1319E800B4F2D2 /* HistoryManager.swift in Sources */, ); diff --git a/Sora.xcodeproj/project.xcworkspace/xcuserdata/Francesco.xcuserdatad/UserInterfaceState.xcuserstate b/Sora.xcodeproj/project.xcworkspace/xcuserdata/Francesco.xcuserdatad/UserInterfaceState.xcuserstate index d721477..553de80 100644 Binary files a/Sora.xcodeproj/project.xcworkspace/xcuserdata/Francesco.xcuserdatad/UserInterfaceState.xcuserstate and b/Sora.xcodeproj/project.xcworkspace/xcuserdata/Francesco.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Sora/Utils/CustomPlayer/Components/Double+Extension.swift b/Sora/Utils/CustomPlayer/Components/Double+Extension.swift new file mode 100644 index 0000000..5bf31ed --- /dev/null +++ b/Sora/Utils/CustomPlayer/Components/Double+Extension.swift @@ -0,0 +1,30 @@ +// +// Double+Extension.swift +// AppleMusicSlider +// +// Created by Pratik on 14/01/23. +// +// Thanks to pratikg29 for this code inside his open source project "https://github.com/pratikg29/Custom-Slider-Control?ref=iosexample.com" +// + +import Foundation + +extension Double { + func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = style + formatter.zeroFormattingBehavior = .pad + return formatter.string(from: self) ?? "" + } +} + +extension BinaryFloatingPoint { + func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = style + formatter.zeroFormattingBehavior = .pad + return formatter.string(from: TimeInterval(self)) ?? "" + } +} diff --git a/Sora/Utils/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/Utils/CustomPlayer/Components/MusicProgressSlider.swift new file mode 100644 index 0000000..afb39dd --- /dev/null +++ b/Sora/Utils/CustomPlayer/Components/MusicProgressSlider.swift @@ -0,0 +1,101 @@ +// +// MusicProgressSlider.swift +// Custom Seekbar +// +// Created by Pratik on 08/01/23. +// +// Thanks to pratikg29 for this code inside his open source project "https://github.com/pratikg29/Custom-Slider-Control?ref=iosexample.com" +// + +import SwiftUI + +struct MusicProgressSlider: View { + @Binding var value: T + let inRange: ClosedRange + let activeFillColor: Color + let fillColor: Color + let emptyColor: Color + let height: CGFloat + let onEditingChanged: (Bool) -> Void + + @State private var localRealProgress: T = 0 + @State private var localTempProgress: T = 0 + @GestureState private var isActive: Bool = false + + var body: some View { + GeometryReader { bounds in + ZStack { + VStack { + ZStack(alignment: .center) { + Capsule() + .fill(emptyColor) + Capsule() + .fill(isActive ? activeFillColor : fillColor) + .mask({ + HStack { + Rectangle() + .frame(width: max(bounds.size.width * CGFloat((localRealProgress + localTempProgress)), 0), alignment: .leading) + Spacer(minLength: 0) + } + }) + } + + HStack { + Text(value.asTimeString(style: .positional)) + Spacer(minLength: 0) + Text("-" + (inRange.upperBound - value).asTimeString(style: .positional)) + } + .font(.system(size: 12)) + .foregroundColor(isActive ? fillColor : emptyColor) + } + .frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width, alignment: .center) + .animation(animation, value: isActive) + } + .frame(width: bounds.size.width, height: bounds.size.height, alignment: .center) + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local) + .updating($isActive) { value, state, transaction in + state = true + } + .onChanged { gesture in + localTempProgress = T(gesture.translation.width / bounds.size.width) + value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) + }.onEnded { value in + localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0) + localTempProgress = 0 + }) + .onChange(of: isActive) { newValue in + value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) + onEditingChanged(newValue) + } + .onAppear { + localRealProgress = getPrgPercentage(value) + } + .onChange(of: value) { newValue in + if !isActive { + localRealProgress = getPrgPercentage(newValue) + } + } + } + .frame(height: isActive ? height * 1.25 : height, alignment: .center) + } + + private var animation: Animation { + if isActive { + return .spring() + } else { + return .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6) + } + } + + private func getPrgPercentage(_ value: T) -> T { + let range = inRange.upperBound - inRange.lowerBound + let correctedStartValue = value - inRange.lowerBound + let percentage = correctedStartValue / range + return percentage + } + + private func getPrgValue() -> T { + return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound + } +} diff --git a/Sora/Utils/CustomPlayer/CustomPlayer.swift b/Sora/Utils/CustomPlayer/CustomPlayer.swift new file mode 100644 index 0000000..e902749 --- /dev/null +++ b/Sora/Utils/CustomPlayer/CustomPlayer.swift @@ -0,0 +1,233 @@ +// +// ContentView.swift +// test2 +// +// Created by Francesco on 20/12/24. +// + +import SwiftUI +import AVKit + +struct CustomVideoPlayer: UIViewControllerRepresentable { + let player: AVPlayer + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let controller = CustomAVPlayerViewController() + controller.player = player + controller.showsPlaybackControls = false + player.play() + return controller + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { + // yes? Like the plural of the famous american rapper ye? -IBHRAD + } +} + +class CustomAVPlayerViewController: AVPlayerViewController { + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UserDefaults.standard.bool(forKey: "alwaysLandscape") { + return .landscape + } else { + return .all + } + } +} + +struct CustomMediaPlayer: View { + @State private var player: AVPlayer + @State private var isPlaying = true + @State private var currentTime: Double = 0.0 + @State private var duration: Double = 0.0 + @State private var showControls = false + @State private var inactivityTimer: Timer? + + init(urlString: String) { + guard let url = URL(string: urlString) else { + fatalError("Invalid URL string") + } + _player = State(initialValue: AVPlayer(url: url)) + } + + var body: some View { + VStack { + ZStack { + CustomVideoPlayer(player: player) + .onAppear { + player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 1, preferredTimescale: 600), queue: .main) { time in + currentTime = time.seconds + if let itemDuration = player.currentItem?.duration.seconds, itemDuration.isFinite && !itemDuration.isNaN { + duration = itemDuration + } + } + startUpdatingCurrentTime() + } + .edgesIgnoringSafeArea(.all) + .overlay( + Group { + if showControls { + Color.black.opacity(0.5) + .edgesIgnoringSafeArea(.all) + HStack(spacing: 20) { + Button(action: { + currentTime = max(currentTime - 10, 0) + player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) + }) { + Image(systemName: "gobackward.10") + } + .foregroundColor(.white) + .font(.system(size: 25)) + .contentShape(Rectangle()) + .frame(width: 60, height: 60) + + Button(action: { + if isPlaying { + player.pause() + } else { + player.play() + } + isPlaying.toggle() + }) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + } + .foregroundColor(.white) + .font(.system(size: 45)) + .contentShape(Rectangle()) + .frame(width: 80, height: 80) + + Button(action: { + currentTime = min(currentTime + 10, duration) + player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) + }) { + Image(systemName: "goforward.10") + } + .foregroundColor(.white) + .font(.system(size: 25)) + .contentShape(Rectangle()) + .frame(width: 60, height: 60) + } + } + }, + alignment: .center + ) + .onTapGesture { + showControls.toggle() + } + + VStack { + Spacer() + if showControls { + VStack { + Spacer() + HStack { + Spacer() + Menu { + Menu("Playback Speed") { + Button(action: { + player.rate = 0.25 + if player.timeControlStatus != .playing { + player.pause() + } + }) { + Label("0.25", systemImage: "tortoise") + } + Button(action: { + player.rate = 0.5 + if player.timeControlStatus != .playing { + player.pause() + } + }) { + Label("0.5", systemImage: "tortoise.fill") + } + Button(action: { + player.rate = 0.75 + if player.timeControlStatus != .playing { + player.pause() + } + }) { + Label("0.75", systemImage: "hare") + } + Button(action: { + player.rate = 1.0 + if player.timeControlStatus != .playing { + player.pause() + } + }) { + Label("1.0", systemImage: "hare.fill") + } + Button(action: { + player.rate = 1.25 + if player.timeControlStatus != .playing { + player.pause() + } + }) { + Label("1.25", systemImage: "speedometer") + } + Button(action: { + player.rate = 1.5 + if player.timeControlStatus != .playing { + player.pause() + } + }) { + Label("1.5", systemImage: "speedometer") + } + Button(action: { + player.rate = 1.75 + if player.timeControlStatus != .playing { + player.pause() + } + }) { + Label("1.75", systemImage: "speedometer") + } + Button(action: { + player.rate = 2.0 + if player.timeControlStatus != .playing { + player.pause() + } + }) { + Label("2.0", systemImage: "speedometer") + } + } + } label: { + Image(systemName: "ellipsis.circle") + .foregroundColor(.white) + .font(.system(size: 15)) + } + } + .padding(.trailing, 10) + + MusicProgressSlider( + value: $currentTime, + inRange: 0...duration, + activeFillColor: .white, + fillColor: .white.opacity(0.5), + emptyColor: .white.opacity(0.3), + height: 28, + onEditingChanged: { editing in + if !editing { + player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) + } + } + ) + .frame(height: 45) + .padding(.bottom, 10) + } + } + } + .onAppear { + startUpdatingCurrentTime() + } + .onDisappear { + player.pause() + inactivityTimer?.invalidate() + } + } + } + } + + private func startUpdatingCurrentTime() { + Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + currentTime = player.currentTime().seconds + } + } +} \ No newline at end of file diff --git a/Sora/Utils/Player/VideoPlayerView.swift b/Sora/Utils/Player/VideoPlayerView.swift index 2dcc942..1d4f422 100644 --- a/Sora/Utils/Player/VideoPlayerView.swift +++ b/Sora/Utils/Player/VideoPlayerView.swift @@ -23,7 +23,7 @@ class VideoPlayerViewController: UIViewController { } player = AVPlayer(url: url) - playerViewController = AVPlayerViewController() + playerViewController = NormalPlayer() playerViewController?.player = player addPeriodicTimeObserver(fullURL: fullUrl) diff --git a/Sora/Views/AnimeViews/AnimeInfoView.swift b/Sora/Views/AnimeViews/AnimeInfoView.swift index 1a29de7..fd64d97 100644 --- a/Sora/Views/AnimeViews/AnimeInfoView.swift +++ b/Sora/Views/AnimeViews/AnimeInfoView.swift @@ -183,6 +183,19 @@ struct AnimeInfoView: View { openInExternalPlayer(scheme: scheme, url: streamUrl) Logger.shared.log("Opening external app with scheme: \(scheme)") return + } else if externalPlayer == "Custom" { + DispatchQueue.main.async { + let customMediaPlayer = CustomMediaPlayer(urlString: streamUrl) + let hostingController = UIHostingController(rootView: customMediaPlayer) + hostingController.modalPresentationStyle = .fullScreen + Logger.shared.log("Opening custom media player with url: \(streamUrl)") + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + rootVC.present(hostingController, animated: true, completion: nil) + } + } + return } DispatchQueue.main.async { diff --git a/Sora/Views/SettingsViews/SubPages/SettingsPlayerView.swift b/Sora/Views/SettingsViews/SubPages/SettingsPlayerView.swift index 9c6c8c9..cde1bcc 100644 --- a/Sora/Views/SettingsViews/SubPages/SettingsPlayerView.swift +++ b/Sora/Views/SettingsViews/SubPages/SettingsPlayerView.swift @@ -14,30 +14,46 @@ struct SettingsPlayerView: View { var body: some View { Form { - Section(header: Text("Player"), footer: Text("The ForceLandscape and HoldSpeed only work inside the default iOS player.")) { + Section(header: Text("Player"), footer: Text("The ForceLandscape and HoldSpeed only work inside the default iOS player and custom player.")) { HStack { Text("Media Player") Spacer() Menu(externalPlayer) { - Button("Default") { + Button(action: { externalPlayer = "Default" + }) { + Label("Default", systemImage: externalPlayer == "Default" ? "checkmark" : "") } - Button("VLC") { + Button(action: { externalPlayer = "VLC" + }) { + Label("VLC", systemImage: externalPlayer == "VLC" ? "checkmark" : "") } - Button("OutPlayer") { + Button(action: { externalPlayer = "OutPlayer" + }) { + Label("OutPlayer", systemImage: externalPlayer == "OutPlayer" ? "checkmark" : "") } - Button("Infuse") { + Button(action: { externalPlayer = "Infuse" + }) { + Label("Infuse", systemImage: externalPlayer == "Infuse" ? "checkmark" : "") } - Button("nPlayer") { + Button(action: { externalPlayer = "nPlayer" + }) { + Label("nPlayer", systemImage: externalPlayer == "nPlayer" ? "checkmark" : "") + } + Button(action: { + externalPlayer = "Custom" + }) { + Label("Custom", systemImage: externalPlayer == "Custom" ? "checkmark" : "") } } } Toggle("Force Landscape", isOn: $isAlwaysLandscape) + .tint(.accentColor) HStack { Text("Hold Speed:")