mirror of
https://github.com/cranci1/Sora.git
synced 2026-05-22 09:32:12 +00:00
TestFlight build (#101)
* Implementation of loading modal * little things but this is good * Update README.md * dim mode * hello 👋 (#95) * bug fix dimming * improved the fetchEpisodeMetadata logic * Aniskip logic and basic buttons (#96) * Aniskip logic and basic buttons * good fuckin enough for now * im callin good enough * bug fix * its something * hallelujah * Update SearchView.swift * made subs go up the progress bar if it is showing --------- Co-authored-by: Seiike <122684677+Seeike@users.noreply.github.com> Co-authored-by: Francesco <100066266+cranci1@users.noreply.github.com> * Updated workflow to change file extension from .zip to .ipa for easier access. (#98) * Update README.md * Auto: Update IPA [skip ci] * Color picker in settings for intro and outro segments (#99) * Color picker in settings for intro and outro segments * Color picker in settings for intro and outro segments * Auto: Update IPA [skip ci] --------- Co-authored-by: cranci1 <cranci1@github.com> --------- Co-authored-by: Ibrahim Sulejmenov <batonchik2004@gmail.com> Co-authored-by: ibro <54913038+xibrox@users.noreply.github.com> Co-authored-by: Seiike <122684677+Seeike@users.noreply.github.com> Co-authored-by: bshar <98615778+bshar1865@users.noreply.github.com> Co-authored-by: cranci1 <cranci1@github.com>
This commit is contained in:
parent
6cf88cb50d
commit
00586fcf3e
14 changed files with 995 additions and 431 deletions
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
|
|
@ -1,29 +1,39 @@
|
||||||
name: Build and Release IPA
|
name: Build and Release IPA
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build IPA
|
name: Build IPA
|
||||||
runs-on: macOS-latest
|
runs-on: macOS-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: true
|
||||||
|
|
||||||
- name: Run ipabuild.sh
|
- name: Run ipabuild.sh
|
||||||
run: |
|
run: |
|
||||||
chmod +x ipabuild.sh
|
chmod +x ipabuild.sh
|
||||||
./ipabuild.sh
|
./ipabuild.sh
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Commit and push latest IPA
|
||||||
uses: actions/upload-artifact@v4
|
run: |
|
||||||
with:
|
git config user.name "cranci1"
|
||||||
name: Sulfur-IPA
|
git config user.email "cranci1@github.com"
|
||||||
path: build/Sulfur.ipa
|
|
||||||
compression-level: 0
|
mkdir -p public-build
|
||||||
|
cp -f build/Sulfur.ipa public-build/Sulfur.ipa
|
||||||
|
|
||||||
|
git add -f public-build/Sulfur.ipa
|
||||||
|
git diff --quiet && git diff --staged --quiet || git commit -m "Auto: Update IPA [skip ci]"
|
||||||
|
git push
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -63,10 +63,9 @@ DerivedData/
|
||||||
*.hmap
|
*.hmap
|
||||||
|
|
||||||
## App packaging
|
## App packaging
|
||||||
*.ipa
|
|
||||||
*.dSYM.zip
|
*.dSYM.zip
|
||||||
*.dSYM
|
*.dSYM
|
||||||
|
public-build
|
||||||
## Playgrounds
|
## Playgrounds
|
||||||
timeline.xctimeline
|
timeline.xctimeline
|
||||||
playground.xcworkspace
|
playground.xcworkspace
|
||||||
|
|
@ -131,4 +130,4 @@ iOSInjectionProject/
|
||||||
/*.gcno
|
/*.gcno
|
||||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/macos,swift,xcode
|
# End of https://www.toptal.com/developers/gitignore/api/macos,swift,xcode
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Sora
|
# Sora
|
||||||
> Also known as Sulfur, for copyright issues.
|
> Also known as Sulfur, for copyright issues.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/Sulfur.png" width="750px">
|
<img src="https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/Sulfur.png" width="750px">
|
||||||
|
|
@ -23,11 +23,13 @@ An iOS and macOS modular web scraping app, under the GPLv3.0 License.
|
||||||
|
|
||||||
- [x] iOS/iPadOS 15.0+ support
|
- [x] iOS/iPadOS 15.0+ support
|
||||||
- [x] macOS support 12.0+
|
- [x] macOS support 12.0+
|
||||||
|
- [x] Sync via iCloud data
|
||||||
- [x] JavaScript module support
|
- [x] JavaScript module support
|
||||||
- [x] Local Library
|
- [x] Tracking Services (AniList, Trakt)
|
||||||
|
- [x] Apple KeyChain support for auth Tokens
|
||||||
- [x] Streams support (Jellyfin/Plex like servers)
|
- [x] Streams support (Jellyfin/Plex like servers)
|
||||||
- [x] External Media players (VLC, infuse, Outplayer, nPlayer)
|
- [x] External Media players (VLC, infuse, Outplayer, nPlayer)
|
||||||
- [x] Tracking Services (AniList, Trakt)
|
- [x] Background playback and Picture-in-Picture (PiP) support
|
||||||
|
|
||||||
## Frequently Asked Questions
|
## Frequently Asked Questions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ struct SoraApp: App {
|
||||||
if isAuthenticated {
|
if isAuthenticated {
|
||||||
Logger.shared.log("Trakt authentication is valid")
|
Logger.shared.log("Trakt authentication is valid")
|
||||||
} else {
|
} else {
|
||||||
Logger.shared.log("Trakt authentication required", type: "Warning")
|
Logger.shared.log("Trakt authentication required", type: "Error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,4 +101,51 @@ class AniListMutation {
|
||||||
|
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchMalID(animeId: Int, completion: @escaping (Result<Int, Error>) -> Void) {
|
||||||
|
let query = """
|
||||||
|
query ($id: Int) {
|
||||||
|
Media(id: $id) {
|
||||||
|
idMal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let variables: [String: Any] = ["id": animeId]
|
||||||
|
let requestBody: [String: Any] = [
|
||||||
|
"query": query,
|
||||||
|
"variables": variables
|
||||||
|
]
|
||||||
|
guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else {
|
||||||
|
completion(.failure(NSError(domain: "", code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to serialize GraphQL request"])))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: apiURL)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
// no auth required for read
|
||||||
|
request.httpBody = jsonData
|
||||||
|
|
||||||
|
URLSession.shared.dataTask(with: request) { data, resp, error in
|
||||||
|
if let e = error {
|
||||||
|
return completion(.failure(e))
|
||||||
|
}
|
||||||
|
guard let data = data,
|
||||||
|
let json = try? JSONDecoder().decode(AniListMediaResponse.self, from: data),
|
||||||
|
let mal = json.data.Media?.idMal else {
|
||||||
|
return completion(.failure(NSError(domain: "", code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Failed to decode AniList response or idMal missing"])))
|
||||||
|
}
|
||||||
|
completion(.success(mal))
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AniListMediaResponse: Decodable {
|
||||||
|
struct DataField: Decodable {
|
||||||
|
struct Media: Decodable { let idMal: Int? }
|
||||||
|
let Media: Media?
|
||||||
|
}
|
||||||
|
let data: DataField
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,30 @@ class VolumeViewModel: ObservableObject {
|
||||||
@Published var value: Double = 0.0
|
@Published var value: Double = 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SliderViewModel: ObservableObject {
|
||||||
|
@Published var sliderValue: Double = 0.0
|
||||||
|
@Published var introSegments: [ClosedRange<Double>] = []
|
||||||
|
@Published var outroSegments: [ClosedRange<Double>] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AniListMediaResponse: Decodable {
|
||||||
|
struct DataField: Decodable {
|
||||||
|
struct Media: Decodable { let idMal: Int? }
|
||||||
|
let Media: Media?
|
||||||
|
}
|
||||||
|
let data: DataField
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AniSkipResponse: Decodable {
|
||||||
|
struct Result: Decodable {
|
||||||
|
struct Interval: Decodable {
|
||||||
|
let startTime: Double
|
||||||
|
let endTime: Double
|
||||||
|
}
|
||||||
|
let interval: Interval
|
||||||
|
let skipType: String
|
||||||
|
}
|
||||||
|
let found: Bool
|
||||||
|
let results: [Result]
|
||||||
|
let statusCode: Int
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
let emptyColor: Color
|
let emptyColor: Color
|
||||||
let height: CGFloat
|
let height: CGFloat
|
||||||
let onEditingChanged: (Bool) -> Void
|
let onEditingChanged: (Bool) -> Void
|
||||||
|
let introSegments: [ClosedRange<T>] // Changed
|
||||||
|
let outroSegments: [ClosedRange<T>] // Changed
|
||||||
|
let introColor: Color
|
||||||
|
let outroColor: Color
|
||||||
|
|
||||||
@State private var localRealProgress: T = 0
|
@State private var localRealProgress: T = 0
|
||||||
@State private var localTempProgress: T = 0
|
@State private var localTempProgress: T = 0
|
||||||
|
|
@ -26,10 +30,38 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { bounds in
|
GeometryReader { bounds in
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack (spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
Capsule()
|
ZStack(alignment: .center) {
|
||||||
.fill(emptyColor)
|
// Intro Segments
|
||||||
|
ForEach(introSegments, id: \.self) { segment in
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
.frame(width: bounds.size.width * CGFloat(segment.lowerBound))
|
||||||
|
Rectangle()
|
||||||
|
.fill(introColor.opacity(0.5))
|
||||||
|
.frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outro Segments
|
||||||
|
ForEach(outroSegments, id: \.self) { segment in
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
.frame(width: bounds.size.width * CGFloat(segment.lowerBound))
|
||||||
|
Rectangle()
|
||||||
|
.fill(outroColor.opacity(0.5))
|
||||||
|
.frame(width: bounds.size.width * CGFloat(segment.upperBound - segment.lowerBound))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest of the existing code...
|
||||||
|
Capsule()
|
||||||
|
.fill(emptyColor)
|
||||||
|
}
|
||||||
|
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(isActive ? activeFillColor : fillColor)
|
.fill(isActive ? activeFillColor : fillColor)
|
||||||
.mask({
|
.mask({
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,6 @@ import AVKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
|
|
||||||
// MARK: - SliderViewModel
|
|
||||||
|
|
||||||
class SliderViewModel: ObservableObject {
|
|
||||||
@Published var sliderValue: Double = 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CustomMediaPlayerViewController
|
// MARK: - CustomMediaPlayerViewController
|
||||||
|
|
||||||
class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDelegate {
|
class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDelegate {
|
||||||
|
|
@ -72,7 +65,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
var landscapeButtonHiddenConstraints: [NSLayoutConstraint] = []
|
var landscapeButtonHiddenConstraints: [NSLayoutConstraint] = []
|
||||||
var currentMarqueeConstraints: [NSLayoutConstraint] = []
|
var currentMarqueeConstraints: [NSLayoutConstraint] = []
|
||||||
private var currentMenuButtonTrailing: NSLayoutConstraint!
|
private var currentMenuButtonTrailing: NSLayoutConstraint!
|
||||||
|
|
||||||
|
|
||||||
var subtitleForegroundColor: String = "white"
|
var subtitleForegroundColor: String = "white"
|
||||||
var subtitleBackgroundEnabled: Bool = true
|
var subtitleBackgroundEnabled: Bool = true
|
||||||
|
|
@ -115,17 +108,49 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
var watchNextButtonControlsConstraints: [NSLayoutConstraint] = []
|
var watchNextButtonControlsConstraints: [NSLayoutConstraint] = []
|
||||||
var isControlsVisible = false
|
var isControlsVisible = false
|
||||||
|
|
||||||
var subtitleBottomConstraint: NSLayoutConstraint?
|
private var subtitleBottomToSliderConstraint: NSLayoutConstraint?
|
||||||
|
private var subtitleBottomToSafeAreaConstraint: NSLayoutConstraint?
|
||||||
var subtitleBottomPadding: CGFloat = 10.0 {
|
var subtitleBottomPadding: CGFloat = 10.0 {
|
||||||
didSet {
|
didSet {
|
||||||
updateSubtitleLabelConstraints()
|
updateSubtitleLabelConstraints()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var malID: Int?
|
||||||
|
private var skipIntervals: (op: CMTimeRange?, ed: CMTimeRange?) = (nil, nil)
|
||||||
|
|
||||||
|
private var skipIntroButton: UIButton!
|
||||||
|
private var skipOutroButton: UIButton!
|
||||||
|
private let skipButtonBaseAlpha: CGFloat = 0.9
|
||||||
|
@Published var segments: [ClosedRange<Double>] = []
|
||||||
|
|
||||||
private var playerItemKVOContext = 0
|
private var playerItemKVOContext = 0
|
||||||
private var loadedTimeRangesObservation: NSKeyValueObservation?
|
private var loadedTimeRangesObservation: NSKeyValueObservation?
|
||||||
private var playerTimeControlStatusObserver: NSKeyValueObservation?
|
private var playerTimeControlStatusObserver: NSKeyValueObservation?
|
||||||
|
|
||||||
|
private var isDimmed = false
|
||||||
|
private var dimButton: UIButton!
|
||||||
|
private var dimButtonToSlider: NSLayoutConstraint!
|
||||||
|
private var dimButtonToRight: NSLayoutConstraint!
|
||||||
|
private var dimButtonTimer: Timer?
|
||||||
|
|
||||||
|
private lazy var controlsToHide: [UIView] = [
|
||||||
|
dismissButton,
|
||||||
|
playPauseButton,
|
||||||
|
backwardButton,
|
||||||
|
forwardButton,
|
||||||
|
sliderHostingController!.view,
|
||||||
|
skip85Button,
|
||||||
|
marqueeLabel,
|
||||||
|
menuButton,
|
||||||
|
qualityButton,
|
||||||
|
speedButton,
|
||||||
|
watchNextButton,
|
||||||
|
volumeSliderHostingView!
|
||||||
|
]
|
||||||
|
|
||||||
|
private var originalHiddenStates: [UIView: Bool] = [:]
|
||||||
|
|
||||||
private var volumeObserver: NSKeyValueObservation?
|
private var volumeObserver: NSKeyValueObservation?
|
||||||
private var audioSession = AVAudioSession.sharedInstance()
|
private var audioSession = AVAudioSession.sharedInstance()
|
||||||
private var hiddenVolumeView = MPVolumeView(frame: .zero)
|
private var hiddenVolumeView = MPVolumeView(frame: .zero)
|
||||||
|
|
@ -195,16 +220,32 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
setupSubtitleLabel()
|
setupSubtitleLabel()
|
||||||
setupDismissButton()
|
setupDismissButton()
|
||||||
volumeSlider()
|
volumeSlider()
|
||||||
|
setupDimButton()
|
||||||
setupSpeedButton()
|
setupSpeedButton()
|
||||||
setupQualityButton()
|
setupQualityButton()
|
||||||
setupMenuButton()
|
setupMenuButton()
|
||||||
setupMarqueeLabel()
|
setupMarqueeLabel()
|
||||||
setupSkip85Button()
|
setupSkip85Button()
|
||||||
|
setupSkipButtons()
|
||||||
addTimeObserver()
|
addTimeObserver()
|
||||||
startUpdateTimer()
|
startUpdateTimer()
|
||||||
setupAudioSession()
|
setupAudioSession()
|
||||||
|
updateSkipButtonsVisibility()
|
||||||
|
|
||||||
|
|
||||||
|
AniListMutation().fetchMalID(animeId: aniListID) { [weak self] result in
|
||||||
|
switch result {
|
||||||
|
case .success(let mal):
|
||||||
|
self?.malID = mal
|
||||||
|
self?.fetchSkipTimes(type: "op")
|
||||||
|
self?.fetchSkipTimes(type: "ed")
|
||||||
|
case .failure(let error):
|
||||||
|
Logger.shared.log("⚠️ Unable to fetch MAL ID: \(error)",type:"Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsToHide.forEach { originalHiddenStates[$0] = $0.isHidden }
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
self?.checkForHLSStream()
|
self?.checkForHLSStream()
|
||||||
}
|
}
|
||||||
|
|
@ -336,10 +377,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
// 1) reveal the quality button
|
// 1) reveal the quality button
|
||||||
self.qualityButton.isHidden = false
|
self.qualityButton.isHidden = false
|
||||||
self.qualityButton.menu = self.qualitySelectionMenu()
|
self.qualityButton.menu = self.qualitySelectionMenu()
|
||||||
|
|
||||||
// 2) update the trailing constraint for the menuButton
|
// 2) update the trailing constraint for the menuButton
|
||||||
self.updateMenuButtonConstraints()
|
self.updateMenuButtonConstraints()
|
||||||
|
|
||||||
// 3) animate the shift
|
// 3) animate the shift
|
||||||
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
|
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
|
||||||
self.view.layoutIfNeeded()
|
self.view.layoutIfNeeded()
|
||||||
|
|
@ -348,6 +389,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getSegmentsColor() -> Color {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: "segmentsColorData"),
|
||||||
|
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
|
||||||
|
return Color(uiColor)
|
||||||
|
}
|
||||||
|
return .yellow
|
||||||
|
}
|
||||||
|
|
||||||
func setupPlayerViewController() {
|
func setupPlayerViewController() {
|
||||||
playerViewController = AVPlayerViewController()
|
playerViewController = AVPlayerViewController()
|
||||||
playerViewController.player = player
|
playerViewController.player = player
|
||||||
|
|
@ -428,7 +477,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
playPauseTap.delaysTouchesBegan = false
|
playPauseTap.delaysTouchesBegan = false
|
||||||
playPauseTap.delegate = self
|
playPauseTap.delegate = self
|
||||||
playPauseButton.addGestureRecognizer(playPauseTap)
|
playPauseButton.addGestureRecognizer(playPauseTap)
|
||||||
|
|
||||||
|
|
||||||
playPauseButton.addGestureRecognizer(playPauseTap)
|
playPauseButton.addGestureRecognizer(playPauseTap)
|
||||||
controlsContainerView.addSubview(playPauseButton)
|
controlsContainerView.addSubview(playPauseButton)
|
||||||
|
|
@ -458,6 +507,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
controlsContainerView.addSubview(forwardButton)
|
controlsContainerView.addSubview(forwardButton)
|
||||||
forwardButton.translatesAutoresizingMaskIntoConstraints = false
|
forwardButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let segmentsColor = self.getSegmentsColor()
|
||||||
|
|
||||||
let sliderView = MusicProgressSlider(
|
let sliderView = MusicProgressSlider(
|
||||||
value: Binding(
|
value: Binding(
|
||||||
get: { self.sliderViewModel.sliderValue },
|
get: { self.sliderViewModel.sliderValue },
|
||||||
|
|
@ -489,7 +540,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
introSegments: sliderViewModel.introSegments, // Added
|
||||||
|
outroSegments: sliderViewModel.outroSegments, // Added
|
||||||
|
introColor: segmentsColor, // Add your colors here
|
||||||
|
outroColor: segmentsColor // Or use settings.accentColor
|
||||||
)
|
)
|
||||||
|
|
||||||
sliderHostingController = UIHostingController(rootView: sliderView)
|
sliderHostingController = UIHostingController(rootView: sliderView)
|
||||||
|
|
@ -641,19 +696,27 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
subtitleLabel.textAlignment = .center
|
subtitleLabel.textAlignment = .center
|
||||||
subtitleLabel.numberOfLines = 0
|
subtitleLabel.numberOfLines = 0
|
||||||
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
||||||
updateSubtitleLabelAppearance()
|
|
||||||
view.addSubview(subtitleLabel)
|
view.addSubview(subtitleLabel)
|
||||||
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
subtitleBottomConstraint = subtitleLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -subtitleBottomPadding)
|
subtitleBottomToSliderConstraint = subtitleLabel.bottomAnchor.constraint(
|
||||||
|
equalTo: sliderHostingController!.view.topAnchor,
|
||||||
|
constant: -20
|
||||||
|
)
|
||||||
|
|
||||||
|
subtitleBottomToSafeAreaConstraint = subtitleLabel.bottomAnchor.constraint(
|
||||||
|
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||||
|
constant: -subtitleBottomPadding
|
||||||
|
)
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
subtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
subtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
subtitleBottomConstraint!,
|
|
||||||
subtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
|
subtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
|
||||||
subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
|
subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
subtitleBottomToSafeAreaConstraint?.isActive = true
|
||||||
|
|
||||||
topSubtitleLabel = UILabel()
|
topSubtitleLabel = UILabel()
|
||||||
topSubtitleLabel.textAlignment = .center
|
topSubtitleLabel.textAlignment = .center
|
||||||
topSubtitleLabel.numberOfLines = 0
|
topSubtitleLabel.numberOfLines = 0
|
||||||
|
|
@ -662,6 +725,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
view.addSubview(topSubtitleLabel)
|
view.addSubview(topSubtitleLabel)
|
||||||
topSubtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
topSubtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
updateSubtitleLabelAppearance()
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
topSubtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
topSubtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
topSubtitleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 30),
|
topSubtitleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 30),
|
||||||
|
|
@ -671,7 +736,12 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSubtitleLabelConstraints() {
|
func updateSubtitleLabelConstraints() {
|
||||||
subtitleBottomConstraint?.constant = -subtitleBottomPadding
|
if isControlsVisible {
|
||||||
|
subtitleBottomToSliderConstraint?.constant = -20
|
||||||
|
} else {
|
||||||
|
subtitleBottomToSafeAreaConstraint?.constant = -subtitleBottomPadding
|
||||||
|
}
|
||||||
|
|
||||||
view.setNeedsLayout()
|
view.setNeedsLayout()
|
||||||
UIView.animate(withDuration: 0.2) {
|
UIView.animate(withDuration: 0.2) {
|
||||||
self.view.layoutIfNeeded()
|
self.view.layoutIfNeeded()
|
||||||
|
|
@ -783,6 +853,215 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateSkipButtonsVisibility() {
|
||||||
|
let t = currentTimeVal
|
||||||
|
let controlsShowing = isControlsVisible // true ⇒ main UI is on‑screen
|
||||||
|
|
||||||
|
func handle(_ button: UIButton, range: CMTimeRange?) {
|
||||||
|
guard let r = range else { button.isHidden = true; return }
|
||||||
|
|
||||||
|
let inInterval = t >= r.start.seconds && t <= r.end.seconds
|
||||||
|
let target = controlsShowing ? 0.0 : skipButtonBaseAlpha
|
||||||
|
|
||||||
|
if inInterval {
|
||||||
|
if button.isHidden {
|
||||||
|
button.alpha = 0
|
||||||
|
}
|
||||||
|
button.isHidden = false
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.25) {
|
||||||
|
button.alpha = target
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !button.isHidden else { return }
|
||||||
|
UIView.animate(withDuration: 0.15, animations: {
|
||||||
|
button.alpha = 0
|
||||||
|
}) { _ in
|
||||||
|
button.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(skipIntroButton, range: skipIntervals.op)
|
||||||
|
handle(skipOutroButton, range: skipIntervals.ed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSegments() {
|
||||||
|
sliderViewModel.introSegments.removeAll()
|
||||||
|
sliderViewModel.outroSegments.removeAll()
|
||||||
|
|
||||||
|
if let op = skipIntervals.op {
|
||||||
|
let start = max(0, op.start.seconds / duration)
|
||||||
|
let end = min(1, op.end.seconds / duration)
|
||||||
|
sliderViewModel.introSegments.append(start...end)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ed = skipIntervals.ed {
|
||||||
|
let start = max(0, ed.start.seconds / duration)
|
||||||
|
let end = min(1, ed.end.seconds / duration)
|
||||||
|
sliderViewModel.outroSegments.append(start...end)
|
||||||
|
}
|
||||||
|
|
||||||
|
let segmentsColor = self.getSegmentsColor()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.sliderHostingController?.rootView = MusicProgressSlider(
|
||||||
|
value: Binding(
|
||||||
|
get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) }, // Remove extra ')'
|
||||||
|
set: { self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) } // Remove extra ')'
|
||||||
|
),
|
||||||
|
inRange: 0...(self.duration > 0 ? self.duration : 1.0),
|
||||||
|
activeFillColor: .white,
|
||||||
|
fillColor: .white.opacity(0.6),
|
||||||
|
textColor: .white.opacity(0.7),
|
||||||
|
emptyColor: .white.opacity(0.3),
|
||||||
|
height: 33,
|
||||||
|
onEditingChanged: { editing in
|
||||||
|
if !editing {
|
||||||
|
let targetTime = CMTime(
|
||||||
|
seconds: self.sliderViewModel.sliderValue,
|
||||||
|
preferredTimescale: 600
|
||||||
|
)
|
||||||
|
self.player.seek(to: targetTime)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
introSegments: self.sliderViewModel.introSegments,
|
||||||
|
outroSegments: self.sliderViewModel.outroSegments,
|
||||||
|
introColor: segmentsColor, // Match your color choices
|
||||||
|
outroColor: segmentsColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchSkipTimes(type: String) {
|
||||||
|
guard let mal = malID else { return }
|
||||||
|
let url = URL(string: "https://api.aniskip.com/v2/skip-times/\(mal)/\(episodeNumber)?types=\(type)&episodeLength=0")!
|
||||||
|
URLSession.shared.dataTask(with: url) { data, _, _ in
|
||||||
|
guard let d = data,
|
||||||
|
let resp = try? JSONDecoder().decode(AniSkipResponse.self, from: d),
|
||||||
|
resp.found,
|
||||||
|
let interval = resp.results.first?.interval else { return }
|
||||||
|
|
||||||
|
let range = CMTimeRange(
|
||||||
|
start: CMTime(seconds: interval.startTime, preferredTimescale: 600),
|
||||||
|
end: CMTime(seconds: interval.endTime, preferredTimescale: 600)
|
||||||
|
)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if type == "op" {
|
||||||
|
self.skipIntervals.op = range
|
||||||
|
} else {
|
||||||
|
self.skipIntervals.ed = range
|
||||||
|
}
|
||||||
|
// Update segments only if duration is available
|
||||||
|
if self.duration > 0 {
|
||||||
|
self.updateSegments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupSkipButtons() {
|
||||||
|
let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
|
||||||
|
let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig)
|
||||||
|
|
||||||
|
skipIntroButton = UIButton(type: .system)
|
||||||
|
skipIntroButton.setImage(introImage, for: .normal)
|
||||||
|
skipIntroButton.setTitle(" Skip Intro", for: .normal)
|
||||||
|
skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
|
||||||
|
|
||||||
|
// match skip85Button styling:
|
||||||
|
skipIntroButton.backgroundColor = UIColor(red: 51/255, green: 51/255, blue: 51/255, alpha: 0.8)
|
||||||
|
skipIntroButton.tintColor = .white
|
||||||
|
skipIntroButton.setTitleColor(.white, for: .normal)
|
||||||
|
skipIntroButton.layer.cornerRadius = 15
|
||||||
|
skipIntroButton.alpha = skipButtonBaseAlpha
|
||||||
|
skipIntroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
|
||||||
|
skipIntroButton.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
skipIntroButton.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||||
|
skipIntroButton.layer.shadowOpacity = 0.6
|
||||||
|
skipIntroButton.layer.shadowRadius = 4
|
||||||
|
skipIntroButton.layer.masksToBounds = false
|
||||||
|
|
||||||
|
skipIntroButton.addTarget(self, action: #selector(skipIntro), for: .touchUpInside)
|
||||||
|
view.addSubview(skipIntroButton)
|
||||||
|
skipIntroButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
skipIntroButton.leadingAnchor.constraint(
|
||||||
|
equalTo: sliderHostingController!.view.leadingAnchor),
|
||||||
|
skipIntroButton.bottomAnchor.constraint(
|
||||||
|
equalTo: sliderHostingController!.view.topAnchor, constant: -5)
|
||||||
|
])
|
||||||
|
|
||||||
|
// MARK: – Skip Outro Button
|
||||||
|
let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)
|
||||||
|
let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig)
|
||||||
|
|
||||||
|
skipOutroButton = UIButton(type: .system)
|
||||||
|
skipOutroButton.setImage(outroImage, for: .normal)
|
||||||
|
skipOutroButton.setTitle(" Skip Outro", for: .normal)
|
||||||
|
skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold)
|
||||||
|
|
||||||
|
// same styling as above
|
||||||
|
skipOutroButton.backgroundColor = skipIntroButton.backgroundColor
|
||||||
|
skipOutroButton.tintColor = skipIntroButton.tintColor
|
||||||
|
skipOutroButton.setTitleColor(.white, for: .normal)
|
||||||
|
skipOutroButton.layer.cornerRadius = skipIntroButton.layer.cornerRadius
|
||||||
|
skipOutroButton.alpha = skipIntroButton.alpha
|
||||||
|
skipOutroButton.contentEdgeInsets = skipIntroButton.contentEdgeInsets
|
||||||
|
skipOutroButton.layer.shadowColor = skipIntroButton.layer.shadowColor
|
||||||
|
skipOutroButton.layer.shadowOffset = skipIntroButton.layer.shadowOffset
|
||||||
|
skipOutroButton.layer.shadowOpacity = skipIntroButton.layer.shadowOpacity
|
||||||
|
skipOutroButton.layer.shadowRadius = skipIntroButton.layer.shadowRadius
|
||||||
|
skipOutroButton.layer.masksToBounds = false
|
||||||
|
|
||||||
|
skipOutroButton.addTarget(self, action: #selector(skipOutro), for: .touchUpInside)
|
||||||
|
view.addSubview(skipOutroButton)
|
||||||
|
skipOutroButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
skipOutroButton.leadingAnchor.constraint(
|
||||||
|
equalTo: sliderHostingController!.view.leadingAnchor),
|
||||||
|
skipOutroButton.bottomAnchor.constraint(
|
||||||
|
equalTo: sliderHostingController!.view.topAnchor, constant: -5)
|
||||||
|
])
|
||||||
|
|
||||||
|
view.bringSubviewToFront(skipOutroButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupDimButton() {
|
||||||
|
let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
|
||||||
|
dimButton = UIButton(type: .system)
|
||||||
|
dimButton.setImage(UIImage(systemName: "moon.fill", withConfiguration: cfg), for: .normal)
|
||||||
|
dimButton.tintColor = .white
|
||||||
|
dimButton.addTarget(self, action: #selector(dimTapped), for: .touchUpInside)
|
||||||
|
controlsContainerView.addSubview(dimButton)
|
||||||
|
dimButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
dimButton.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
dimButton.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||||
|
dimButton.layer.shadowOpacity = 0.6
|
||||||
|
dimButton.layer.shadowRadius = 4
|
||||||
|
dimButton.layer.masksToBounds = false
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
dimButton.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor),
|
||||||
|
dimButton.widthAnchor.constraint(equalToConstant: 24),
|
||||||
|
dimButton.heightAnchor.constraint(equalToConstant: 24),
|
||||||
|
])
|
||||||
|
|
||||||
|
dimButtonToSlider = dimButton.trailingAnchor.constraint(
|
||||||
|
equalTo: volumeSliderHostingView!.leadingAnchor,
|
||||||
|
constant: -8
|
||||||
|
)
|
||||||
|
dimButtonToRight = dimButton.trailingAnchor.constraint(
|
||||||
|
equalTo: controlsContainerView.trailingAnchor,
|
||||||
|
constant: -16
|
||||||
|
)
|
||||||
|
|
||||||
|
dimButtonToSlider.isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
func updateMarqueeConstraints() {
|
func updateMarqueeConstraints() {
|
||||||
UIView.performWithoutAnimation {
|
UIView.performWithoutAnimation {
|
||||||
|
|
@ -790,13 +1069,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
|
|
||||||
let leftSpacing: CGFloat = 2
|
let leftSpacing: CGFloat = 2
|
||||||
let rightSpacing: CGFloat = 6
|
let rightSpacing: CGFloat = 6
|
||||||
let trailingAnchor: NSLayoutXAxisAnchor = (volumeSliderHostingView?.isHidden == false)
|
let trailingAnchor: NSLayoutXAxisAnchor = dimButton.leadingAnchor
|
||||||
? volumeSliderHostingView!.leadingAnchor
|
|
||||||
: view.safeAreaLayoutGuide.trailingAnchor
|
|
||||||
|
|
||||||
currentMarqueeConstraints = [
|
currentMarqueeConstraints = [
|
||||||
marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: leftSpacing),
|
marqueeLabel.leadingAnchor.constraint(
|
||||||
marqueeLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -rightSpacing - 10),
|
equalTo: dismissButton.trailingAnchor, constant: leftSpacing),
|
||||||
|
marqueeLabel.trailingAnchor.constraint(
|
||||||
|
equalTo: trailingAnchor, constant: -rightSpacing - 10),
|
||||||
marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor)
|
marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor)
|
||||||
]
|
]
|
||||||
NSLayoutConstraint.activate(currentMarqueeConstraints)
|
NSLayoutConstraint.activate(currentMarqueeConstraints)
|
||||||
|
|
@ -833,7 +1112,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
menuButton.widthAnchor.constraint(equalToConstant: 40),
|
menuButton.widthAnchor.constraint(equalToConstant: 40),
|
||||||
menuButton.heightAnchor.constraint(equalToConstant: 40),
|
menuButton.heightAnchor.constraint(equalToConstant: 40),
|
||||||
])
|
])
|
||||||
|
|
||||||
currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -6)
|
currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -956,25 +1235,31 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSubtitleLabelAppearance() {
|
func updateSubtitleLabelAppearance() {
|
||||||
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
// subtitleLabel always exists here:
|
||||||
subtitleLabel.textColor = subtitleUIColor()
|
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
||||||
subtitleLabel.backgroundColor = subtitleBackgroundEnabled ? UIColor.black.withAlphaComponent(0.6) : .clear
|
subtitleLabel.textColor = subtitleUIColor()
|
||||||
subtitleLabel.layer.cornerRadius = 5
|
subtitleLabel.backgroundColor = subtitleBackgroundEnabled
|
||||||
subtitleLabel.clipsToBounds = true
|
? UIColor.black.withAlphaComponent(0.6)
|
||||||
subtitleLabel.layer.shadowColor = UIColor.black.cgColor
|
: .clear
|
||||||
subtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius)
|
subtitleLabel.layer.cornerRadius = 5
|
||||||
subtitleLabel.layer.shadowOpacity = 1.0
|
subtitleLabel.clipsToBounds = true
|
||||||
subtitleLabel.layer.shadowOffset = CGSize.zero
|
subtitleLabel.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
subtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius)
|
||||||
|
subtitleLabel.layer.shadowOpacity = 1.0
|
||||||
|
subtitleLabel.layer.shadowOffset = .zero
|
||||||
|
|
||||||
topSubtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
// only style it if it’s been created already
|
||||||
topSubtitleLabel.textColor = subtitleUIColor()
|
topSubtitleLabel?.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
|
||||||
topSubtitleLabel.backgroundColor = subtitleBackgroundEnabled ? UIColor.black.withAlphaComponent(0.6) : .clear
|
topSubtitleLabel?.textColor = subtitleUIColor()
|
||||||
topSubtitleLabel.layer.cornerRadius = 5
|
topSubtitleLabel?.backgroundColor = subtitleBackgroundEnabled
|
||||||
topSubtitleLabel.clipsToBounds = true
|
? UIColor.black.withAlphaComponent(0.6)
|
||||||
topSubtitleLabel.layer.shadowColor = UIColor.black.cgColor
|
: .clear
|
||||||
topSubtitleLabel.layer.shadowRadius = CGFloat(subtitleShadowRadius)
|
topSubtitleLabel?.layer.cornerRadius = 5
|
||||||
topSubtitleLabel.layer.shadowOpacity = 1.0
|
topSubtitleLabel?.clipsToBounds = true
|
||||||
topSubtitleLabel.layer.shadowOffset = CGSize.zero
|
topSubtitleLabel?.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
topSubtitleLabel?.layer.shadowRadius = CGFloat(subtitleShadowRadius)
|
||||||
|
topSubtitleLabel?.layer.shadowOpacity = 1.0
|
||||||
|
topSubtitleLabel?.layer.shadowOffset = .zero
|
||||||
}
|
}
|
||||||
|
|
||||||
func subtitleUIColor() -> UIColor {
|
func subtitleUIColor() -> UIColor {
|
||||||
|
|
@ -1003,6 +1288,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
|
|
||||||
self.currentTimeVal = time.seconds
|
self.currentTimeVal = time.seconds
|
||||||
self.duration = currentDuration
|
self.duration = currentDuration
|
||||||
|
self.updateSegments()
|
||||||
|
|
||||||
if !self.isSliderEditing {
|
if !self.isSliderEditing {
|
||||||
self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration))
|
self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration))
|
||||||
|
|
@ -1034,6 +1320,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
self.topSubtitleLabel.isHidden = true
|
self.topSubtitleLabel.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current = self.currentTimeVal
|
||||||
|
|
||||||
|
let segmentsColor = self.getSegmentsColor()
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let currentItem = self.player.currentItem, currentItem.duration.seconds > 0 {
|
if let currentItem = self.player.currentItem, currentItem.duration.seconds > 0 {
|
||||||
let progress = min(max(self.currentTimeVal / self.duration, 0), 1.0)
|
let progress = min(max(self.currentTimeVal / self.duration, 0), 1.0)
|
||||||
|
|
@ -1085,12 +1375,31 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
)
|
)
|
||||||
self.player.seek(to: targetTime)
|
self.player.seek(to: targetTime)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
introSegments: self.sliderViewModel.introSegments,
|
||||||
|
outroSegments: self.sliderViewModel.outroSegments,
|
||||||
|
introColor: segmentsColor, // Match your color choices
|
||||||
|
outroColor: segmentsColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func skipIntro() {
|
||||||
|
if let range = skipIntervals.op {
|
||||||
|
player.seek(to: range.end)
|
||||||
|
// optionally hide button immediately:
|
||||||
|
skipIntroButton.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func skipOutro() {
|
||||||
|
if let range = skipIntervals.ed {
|
||||||
|
player.seek(to: range.end)
|
||||||
|
skipOutroButton.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func startUpdateTimer() {
|
func startUpdateTimer() {
|
||||||
updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
|
|
@ -1100,31 +1409,48 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateMenuButtonConstraints() {
|
func updateMenuButtonConstraints() {
|
||||||
// tear down last one
|
// tear down last one
|
||||||
currentMenuButtonTrailing.isActive = false
|
currentMenuButtonTrailing.isActive = false
|
||||||
|
|
||||||
// pick the “next” visible control
|
// pick the “next” visible control
|
||||||
let anchor: NSLayoutXAxisAnchor
|
let anchor: NSLayoutXAxisAnchor
|
||||||
if !qualityButton.isHidden {
|
if !qualityButton.isHidden {
|
||||||
anchor = qualityButton.leadingAnchor
|
anchor = qualityButton.leadingAnchor
|
||||||
} else if !speedButton.isHidden {
|
} else if !speedButton.isHidden {
|
||||||
anchor = speedButton.leadingAnchor
|
anchor = speedButton.leadingAnchor
|
||||||
} else {
|
} else {
|
||||||
anchor = controlsContainerView.trailingAnchor
|
anchor = controlsContainerView.trailingAnchor
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebuild & activate
|
currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: anchor, constant: -6)
|
||||||
currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: anchor, constant: -6)
|
currentMenuButtonTrailing.isActive = true
|
||||||
currentMenuButtonTrailing.isActive = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func toggleControls() {
|
@objc func toggleControls() {
|
||||||
isControlsVisible.toggle()
|
if isDimmed {
|
||||||
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut, animations: {
|
dimButton.isHidden = false
|
||||||
let alphaVal: CGFloat = self.isControlsVisible ? 1 : 0
|
dimButton.alpha = 1.0
|
||||||
self.controlsContainerView.alpha = alphaVal
|
dimButtonTimer?.invalidate()
|
||||||
self.skip85Button.alpha = alphaVal
|
dimButtonTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
|
||||||
})
|
guard let self = self else { return }
|
||||||
|
UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut]) {
|
||||||
|
self.dimButton.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isControlsVisible.toggle()
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
let a: CGFloat = self.isControlsVisible ? 1 : 0
|
||||||
|
self.controlsContainerView.alpha = a
|
||||||
|
self.skip85Button.alpha = a
|
||||||
|
|
||||||
|
self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible
|
||||||
|
self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible
|
||||||
|
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
self.updateSkipButtonsVisibility()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func seekBackwardLongPress(_ gesture: UILongPressGestureRecognizer) {
|
@objc func seekBackwardLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||||
|
|
@ -1158,7 +1484,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in
|
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in
|
||||||
guard self != nil else { return }
|
guard self != nil else { return }
|
||||||
}
|
}
|
||||||
animateButtonRotation(backwardButton, clockwise: false)
|
animateButtonRotation(backwardButton, clockwise: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func seekForward() {
|
@objc func seekForward() {
|
||||||
|
|
@ -1191,19 +1517,16 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
playPauseButton.image = UIImage(systemName: "play.fill")
|
playPauseButton.image = UIImage(systemName: "play.fill")
|
||||||
|
|
||||||
// Defer the UI animation so that it doesn't block the pause call
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if !self.isControlsVisible {
|
if !self.isControlsVisible {
|
||||||
self.isControlsVisible = true
|
self.isControlsVisible = true
|
||||||
UIView.animate(withDuration: 0.1, animations: {
|
UIView.animate(withDuration: 0.1, animations: {
|
||||||
self.controlsContainerView.alpha = 1.0
|
self.controlsContainerView.alpha = 1.0
|
||||||
self.skip85Button.alpha = 0.8
|
self.skip85Button.alpha = 0.8
|
||||||
// Removed layoutIfNeeded() to avoid forcing a layout pass here
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Play immediately
|
|
||||||
player.play()
|
player.play()
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
playPauseButton.image = UIImage(systemName: "pause.fill")
|
playPauseButton.image = UIImage(systemName: "pause.fill")
|
||||||
|
|
@ -1234,6 +1557,30 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func dimTapped() {
|
||||||
|
isDimmed.toggle()
|
||||||
|
dimButtonTimer?.invalidate()
|
||||||
|
|
||||||
|
// animate black overlay
|
||||||
|
UIView.animate(withDuration: 0.25) {
|
||||||
|
self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
// fade controls instead of hiding
|
||||||
|
UIView.animate(withDuration: 0.25) {
|
||||||
|
for view in self.controlsToHide {
|
||||||
|
view.alpha = self.isDimmed ? 0 : 1
|
||||||
|
}
|
||||||
|
// keep the dim button visible/in front
|
||||||
|
self.dimButton.alpha = self.isDimmed ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// swap your trailing constraints on the dim‑button
|
||||||
|
dimButtonToSlider.isActive = !isDimmed
|
||||||
|
dimButtonToRight.isActive = isDimmed
|
||||||
|
UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() }
|
||||||
|
}
|
||||||
|
|
||||||
func speedChangerMenu() -> UIMenu {
|
func speedChangerMenu() -> UIMenu {
|
||||||
let speeds: [Double] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
let speeds: [Double] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||||
let playbackSpeedActions = speeds.map { speed in
|
let playbackSpeedActions = speeds.map { speed in
|
||||||
|
|
@ -1288,24 +1635,24 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
button.superview?.layoutIfNeeded()
|
button.superview?.layoutIfNeeded()
|
||||||
|
|
||||||
button.layer.shouldRasterize = true
|
button.layer.shouldRasterize = true
|
||||||
button.layer.rasterizationScale = UIScreen.main.scale
|
button.layer.rasterizationScale = UIScreen.main.scale
|
||||||
button.layer.allowsEdgeAntialiasing = true
|
button.layer.allowsEdgeAntialiasing = true
|
||||||
|
|
||||||
let rotation = CABasicAnimation(keyPath: "transform.rotation.z")
|
let rotation = CABasicAnimation(keyPath: "transform.rotation.z")
|
||||||
rotation.fromValue = 0
|
rotation.fromValue = 0
|
||||||
rotation.toValue = CGFloat.pi * 2 * (clockwise ? 1 : -1)
|
rotation.toValue = CGFloat.pi * 2 * (clockwise ? 1 : -1)
|
||||||
rotation.duration = 0.43
|
rotation.duration = 0.43
|
||||||
rotation.timingFunction = CAMediaTimingFunction(name: .linear)
|
rotation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||||
|
|
||||||
button.layer.add(rotation, forKey: "rotate360")
|
button.layer.add(rotation, forKey: "rotate360")
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + rotation.duration) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + rotation.duration) {
|
||||||
button.layer.shouldRasterize = false
|
button.layer.shouldRasterize = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func parseM3U8(url: URL, completion: @escaping () -> Void) {
|
private func parseM3U8(url: URL, completion: @escaping () -> Void) {
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
|
|
@ -1793,7 +2140,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
||||||
height: 10,
|
height: 10,
|
||||||
onEditingChanged: { _ in }
|
onEditingChanged: { _ in }
|
||||||
)
|
)
|
||||||
.shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2)
|
.shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ class iCloudSyncManager {
|
||||||
|
|
||||||
func syncModulesFromiCloud() {
|
func syncModulesFromiCloud() {
|
||||||
guard let iCloudURL = self.ubiquityContainerURL else {
|
guard let iCloudURL = self.ubiquityContainerURL else {
|
||||||
Logger.shared.log("iCloud container not available", type: "Warning")
|
Logger.shared.log("iCloud container not available", type: "Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ struct EpisodeCell: View {
|
||||||
@State private var isLoading: Bool = true
|
@State private var isLoading: Bool = true
|
||||||
@State private var currentProgress: Double = 0.0
|
@State private var currentProgress: Double = 0.0
|
||||||
|
|
||||||
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
|
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
|
||||||
itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
|
itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
|
||||||
self.episodeIndex = episodeIndex
|
self.episodeIndex = episodeIndex
|
||||||
self.episode = episode
|
self.episode = episode
|
||||||
|
|
@ -92,13 +92,9 @@ struct EpisodeCell: View {
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateProgress()
|
updateProgress()
|
||||||
|
fetchEpisodeDetails()
|
||||||
if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil
|
|
||||||
|| UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
|
|
||||||
fetchEpisodeDetails()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: progress) { newProgress in
|
.onChange(of: progress) { _ in
|
||||||
updateProgress()
|
updateProgress()
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
|
|
@ -147,16 +143,12 @@ struct EpisodeCell: View {
|
||||||
URLSession.custom.dataTask(with: url) { data, _, error in
|
URLSession.custom.dataTask(with: url) { data, _, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
Logger.shared.log("Failed to fetch anime episode details: \(error)", type: "Error")
|
Logger.shared.log("Failed to fetch anime episode details: \(error)", type: "Error")
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { self.isLoading = false }
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { self.isLoading = false }
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,21 +160,20 @@ struct EpisodeCell: View {
|
||||||
let title = episodeDetails["title"] as? [String: String],
|
let title = episodeDetails["title"] as? [String: String],
|
||||||
let image = episodeDetails["image"] as? String else {
|
let image = episodeDetails["image"] as? String else {
|
||||||
Logger.shared.log("Invalid anime response format", type: "Error")
|
Logger.shared.log("Invalid anime response format", type: "Error")
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { self.isLoading = false }
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.episodeTitle = title["en"] ?? ""
|
|
||||||
self.episodeImageUrl = image
|
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil
|
||||||
|
|| UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
|
||||||
|
self.episodeTitle = title["en"] ?? ""
|
||||||
|
self.episodeImageUrl = image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { self.isLoading = false }
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.resume()
|
}.resume()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,242 +52,298 @@ struct MediaInfoView: View {
|
||||||
@State private var selectedRange: Range<Int> = 0..<100
|
@State private var selectedRange: Range<Int> = 0..<100
|
||||||
@State private var showSettingsMenu = false
|
@State private var showSettingsMenu = false
|
||||||
@State private var customAniListID: Int?
|
@State private var customAniListID: Int?
|
||||||
|
@State private var showStreamLoadingView: Bool = false
|
||||||
|
@State private var currentStreamTitle: String = ""
|
||||||
|
|
||||||
|
@State private var activeFetchID: UUID? = nil
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
private var isGroupedBySeasons: Bool {
|
private var isGroupedBySeasons: Bool {
|
||||||
return groupedEpisodes().count > 1
|
return groupedEpisodes().count > 1
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
ZStack {
|
||||||
if isLoading {
|
Group {
|
||||||
ProgressView()
|
if isLoading {
|
||||||
.padding()
|
ProgressView()
|
||||||
} else {
|
.padding()
|
||||||
ScrollView {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
ScrollView {
|
||||||
HStack(alignment: .top, spacing: 10) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
KFImage(URL(string: imageUrl))
|
HStack(alignment: .top, spacing: 10) {
|
||||||
.placeholder {
|
KFImage(URL(string: imageUrl))
|
||||||
RoundedRectangle(cornerRadius: 10)
|
.placeholder {
|
||||||
.fill(Color.gray.opacity(0.3))
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.frame(width: 150, height: 225)
|
.fill(Color.gray.opacity(0.3))
|
||||||
.shimmering()
|
.frame(width: 150, height: 225)
|
||||||
}
|
.shimmering()
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 150, height: 225)
|
|
||||||
.clipped()
|
|
||||||
.cornerRadius(10)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(title)
|
|
||||||
.font(.system(size: 17))
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.onLongPressGesture {
|
|
||||||
UIPasteboard.general.string = title
|
|
||||||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
|
||||||
}
|
}
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 150, height: 225)
|
||||||
|
.clipped()
|
||||||
|
.cornerRadius(10)
|
||||||
|
|
||||||
if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(aliases)
|
Text(title)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 17))
|
||||||
.foregroundColor(.secondary)
|
.fontWeight(.bold)
|
||||||
}
|
.onLongPressGesture {
|
||||||
|
UIPasteboard.general.string = title
|
||||||
Spacer()
|
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||||
|
|
||||||
if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
|
|
||||||
HStack(alignment: .center, spacing: 12) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "calendar")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 15, height: 15)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Text(airdate)
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
.padding(4)
|
|
||||||
|
if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" {
|
||||||
|
Text(aliases)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Spacer()
|
||||||
HStack(alignment: .center, spacing: 12) {
|
|
||||||
Button(action: {
|
if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
|
||||||
openSafariViewController(with: href)
|
HStack(alignment: .center, spacing: 12) {
|
||||||
}) {
|
HStack(spacing: 4) {
|
||||||
HStack(spacing: 4) {
|
Image(systemName: "calendar")
|
||||||
Text(module.metadata.sourceName)
|
.resizable()
|
||||||
.font(.system(size: 13))
|
.frame(width: 15, height: 15)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(airdate)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Button(action: {
|
||||||
|
openSafariViewController(with: href)
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(module.metadata.sourceName)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Image(systemName: "safari")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
.background(Capsule().fill(Color.accentColor.opacity(0.4)))
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
Button(action: {
|
||||||
|
showCustomIDAlert()
|
||||||
|
}) {
|
||||||
|
Label("Set Custom AniList ID", systemImage: "number")
|
||||||
|
}
|
||||||
|
|
||||||
Image(systemName: "safari")
|
if let customID = customAniListID {
|
||||||
|
Button(action: {
|
||||||
|
customAniListID = nil
|
||||||
|
itemID = nil
|
||||||
|
fetchItemID(byTitle: cleanTitle(title)) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let id):
|
||||||
|
itemID = id
|
||||||
|
case .failure(let error):
|
||||||
|
Logger.shared.log("Failed to fetch AniList ID: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Label("Reset AniList ID", systemImage: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let id = itemID ?? customAniListID {
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "https://anilist.co/anime/\(id)") {
|
||||||
|
openSafariViewController(with: url.absoluteString)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Label("Open in AniList", systemImage: "link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug")
|
||||||
|
DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal"))
|
||||||
|
}) {
|
||||||
|
Label("Log Debug Info", systemImage: "terminal")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 20, height: 20)
|
.frame(width: 20, height: 20)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
}
|
}
|
||||||
.padding(4)
|
}
|
||||||
.background(Capsule().fill(Color.accentColor.opacity(0.4)))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !synopsis.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
Text("Synopsis")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showFullSynopsis.toggle()
|
||||||
|
}) {
|
||||||
|
Text(showFullSynopsis ? "Less" : "More")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Menu {
|
Text(synopsis)
|
||||||
Button(action: {
|
.lineLimit(showFullSynopsis ? nil : 4)
|
||||||
showCustomIDAlert()
|
.font(.system(size: 14))
|
||||||
}) {
|
}
|
||||||
Label("Set Custom AniList ID", systemImage: "number")
|
}
|
||||||
}
|
|
||||||
|
HStack {
|
||||||
if let customID = customAniListID {
|
Button(action: {
|
||||||
Button(action: {
|
playFirstUnwatchedEpisode()
|
||||||
customAniListID = nil
|
}) {
|
||||||
itemID = nil
|
HStack {
|
||||||
fetchItemID(byTitle: cleanTitle(title)) { result in
|
Image(systemName: "play.fill")
|
||||||
switch result {
|
.foregroundColor(.primary)
|
||||||
case .success(let id):
|
Text(startWatchingText)
|
||||||
itemID = id
|
.font(.headline)
|
||||||
case .failure(let error):
|
|
||||||
Logger.shared.log("Failed to fetch AniList ID: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Label("Reset AniList ID", systemImage: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let id = itemID ?? customAniListID {
|
|
||||||
Button(action: {
|
|
||||||
if let url = URL(string: "https://anilist.co/anime/\(id)") {
|
|
||||||
openSafariViewController(with: url.absoluteString)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Label("Open in AniList", systemImage: "link")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug")
|
|
||||||
DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal"))
|
|
||||||
}) {
|
|
||||||
Label("Log Debug Info", systemImage: "terminal")
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "ellipsis.circle")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.cornerRadius(10)
|
||||||
}
|
}
|
||||||
}
|
.disabled(isFetchingEpisode)
|
||||||
}
|
.id(buttonRefreshTrigger)
|
||||||
|
|
||||||
if !synopsis.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
HStack(alignment: .center) {
|
|
||||||
Text("Synopsis")
|
|
||||||
.font(.system(size: 18))
|
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showFullSynopsis.toggle()
|
|
||||||
}) {
|
|
||||||
Text(showFullSynopsis ? "Less" : "More")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(synopsis)
|
Button(action: {
|
||||||
.lineLimit(showFullSynopsis ? nil : 4)
|
libraryManager.toggleBookmark(
|
||||||
.font(.system(size: 14))
|
title: title,
|
||||||
}
|
imageUrl: imageUrl,
|
||||||
}
|
href: href,
|
||||||
|
moduleId: module.id.uuidString,
|
||||||
HStack {
|
moduleName: module.metadata.sourceName
|
||||||
Button(action: {
|
)
|
||||||
playFirstUnwatchedEpisode()
|
}) {
|
||||||
}) {
|
Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
|
||||||
HStack {
|
.resizable()
|
||||||
Image(systemName: "play.fill")
|
.frame(width: 20, height: 27)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(Color.accentColor)
|
||||||
Text(startWatchingText)
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.background(Color.accentColor)
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
}
|
||||||
.disabled(isFetchingEpisode)
|
|
||||||
.id(buttonRefreshTrigger)
|
|
||||||
|
|
||||||
Button(action: {
|
if !episodeLinks.isEmpty {
|
||||||
libraryManager.toggleBookmark(
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
title: title,
|
HStack {
|
||||||
imageUrl: imageUrl,
|
Text("Episodes")
|
||||||
href: href,
|
.font(.system(size: 18))
|
||||||
moduleId: module.id.uuidString,
|
.fontWeight(.bold)
|
||||||
moduleName: module.metadata.sourceName
|
|
||||||
)
|
Spacer()
|
||||||
}) {
|
|
||||||
Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
|
if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
|
||||||
.resizable()
|
|
||||||
.frame(width: 20, height: 27)
|
|
||||||
.foregroundColor(Color.accentColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !episodeLinks.isEmpty {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
HStack {
|
|
||||||
Text("Episodes")
|
|
||||||
.font(.system(size: 18))
|
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
|
|
||||||
Menu {
|
|
||||||
ForEach(generateRanges(), id: \.self) { range in
|
|
||||||
Button(action: { selectedRange = range }) {
|
|
||||||
Text("\(range.lowerBound + 1)-\(range.upperBound)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
|
||||||
} else if isGroupedBySeasons {
|
|
||||||
let seasons = groupedEpisodes()
|
|
||||||
if seasons.count > 1 {
|
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(0..<seasons.count, id: \.self) { index in
|
ForEach(generateRanges(), id: \.self) { range in
|
||||||
Button(action: { selectedSeason = index }) {
|
Button(action: { selectedRange = range }) {
|
||||||
Text("Season \(index + 1)")
|
Text("\(range.lowerBound + 1)-\(range.upperBound)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("Season \(selectedSeason + 1)")
|
Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
}
|
}
|
||||||
|
} else if isGroupedBySeasons {
|
||||||
|
let seasons = groupedEpisodes()
|
||||||
|
if seasons.count > 1 {
|
||||||
|
Menu {
|
||||||
|
ForEach(0..<seasons.count, id: \.self) { index in
|
||||||
|
Button(action: { selectedSeason = index }) {
|
||||||
|
Text("Season \(index + 1)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Season \(selectedSeason + 1)")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if isGroupedBySeasons {
|
||||||
if isGroupedBySeasons {
|
let seasons = groupedEpisodes()
|
||||||
let seasons = groupedEpisodes()
|
if !seasons.isEmpty, selectedSeason < seasons.count {
|
||||||
if !seasons.isEmpty, selectedSeason < seasons.count {
|
ForEach(seasons[selectedSeason]) { ep in
|
||||||
ForEach(seasons[selectedSeason]) { ep in
|
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
|
||||||
|
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
|
||||||
|
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
||||||
|
|
||||||
|
EpisodeCell(
|
||||||
|
episodeIndex: selectedSeason,
|
||||||
|
episode: ep.href,
|
||||||
|
episodeID: ep.number - 1,
|
||||||
|
progress: progress,
|
||||||
|
itemID: itemID ?? 0,
|
||||||
|
onTap: { imageUrl in
|
||||||
|
if !isFetchingEpisode {
|
||||||
|
selectedEpisodeNumber = ep.number
|
||||||
|
selectedEpisodeImage = imageUrl
|
||||||
|
fetchStream(href: ep.href)
|
||||||
|
AnalyticsManager.shared.sendEvent(
|
||||||
|
event: "watch",
|
||||||
|
additionalData: ["title": title, "episode": ep.number]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMarkAllPrevious: {
|
||||||
|
let userDefaults = UserDefaults.standard
|
||||||
|
var updates = [String: Double]()
|
||||||
|
|
||||||
|
for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
|
||||||
|
let href = ep2.href
|
||||||
|
updates["lastPlayedTime_\(href)"] = 99999999.0
|
||||||
|
updates["totalTime_\(href)"] = 99999999.0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, value) in updates {
|
||||||
|
userDefaults.set(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
userDefaults.synchronize()
|
||||||
|
|
||||||
|
refreshTrigger.toggle()
|
||||||
|
Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.id(refreshTrigger)
|
||||||
|
.disabled(isFetchingEpisode)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No episodes available")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
|
||||||
|
let ep = episodeLinks[i]
|
||||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
|
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
|
||||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
|
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
|
||||||
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
||||||
|
|
||||||
EpisodeCell(
|
EpisodeCell(
|
||||||
episodeIndex: selectedSeason,
|
episodeIndex: i,
|
||||||
episode: ep.href,
|
episode: ep.href,
|
||||||
episodeID: ep.number - 1,
|
episodeID: ep.number - 1,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
|
|
@ -307,142 +363,148 @@ struct MediaInfoView: View {
|
||||||
let userDefaults = UserDefaults.standard
|
let userDefaults = UserDefaults.standard
|
||||||
var updates = [String: Double]()
|
var updates = [String: Double]()
|
||||||
|
|
||||||
for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
|
for idx in 0..<i {
|
||||||
let href = ep2.href
|
if idx < episodeLinks.count {
|
||||||
updates["lastPlayedTime_\(href)"] = 99999999.0
|
let href = episodeLinks[idx].href
|
||||||
updates["totalTime_\(href)"] = 99999999.0
|
updates["lastPlayedTime_\(href)"] = 1000.0
|
||||||
|
updates["totalTime_\(href)"] = 1000.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (key, value) in updates {
|
for (key, value) in updates {
|
||||||
userDefaults.set(value, forKey: key)
|
userDefaults.set(value, forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
userDefaults.synchronize()
|
|
||||||
|
|
||||||
refreshTrigger.toggle()
|
refreshTrigger.toggle()
|
||||||
Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
|
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.id(refreshTrigger)
|
|
||||||
.disabled(isFetchingEpisode)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text("No episodes available")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
|
|
||||||
let ep = episodeLinks[i]
|
|
||||||
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
|
|
||||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
|
|
||||||
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
|
|
||||||
|
|
||||||
EpisodeCell(
|
|
||||||
episodeIndex: i,
|
|
||||||
episode: ep.href,
|
|
||||||
episodeID: ep.number - 1,
|
|
||||||
progress: progress,
|
|
||||||
itemID: itemID ?? 0,
|
|
||||||
onTap: { imageUrl in
|
|
||||||
if !isFetchingEpisode {
|
|
||||||
selectedEpisodeNumber = ep.number
|
|
||||||
selectedEpisodeImage = imageUrl
|
|
||||||
fetchStream(href: ep.href)
|
|
||||||
AnalyticsManager.shared.sendEvent(
|
|
||||||
event: "watch",
|
|
||||||
additionalData: ["title": title, "episode": ep.number]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMarkAllPrevious: {
|
|
||||||
let userDefaults = UserDefaults.standard
|
|
||||||
var updates = [String: Double]()
|
|
||||||
|
|
||||||
for idx in 0..<i {
|
|
||||||
if idx < episodeLinks.count {
|
|
||||||
let href = episodeLinks[idx].href
|
|
||||||
updates["lastPlayedTime_\(href)"] = 1000.0
|
|
||||||
updates["totalTime_\(href)"] = 1000.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (key, value) in updates {
|
|
||||||
userDefaults.set(value, forKey: key)
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshTrigger.toggle()
|
|
||||||
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.id(refreshTrigger)
|
.id(refreshTrigger)
|
||||||
.disabled(isFetchingEpisode)
|
.disabled(isFetchingEpisode)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Text("Episodes")
|
|
||||||
.font(.system(size: 18))
|
|
||||||
.fontWeight(.bold)
|
|
||||||
}
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
if isRefetching {
|
|
||||||
ProgressView()
|
|
||||||
.padding()
|
|
||||||
} else {
|
|
||||||
Image(systemName: "exclamationmark.triangle")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
HStack(spacing: 2) {
|
|
||||||
Text("No episodes Found:")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Button(action: {
|
|
||||||
isRefetching = true
|
|
||||||
fetchDetails()
|
|
||||||
}) {
|
|
||||||
Text("Retry")
|
|
||||||
.foregroundColor(.accentColor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Episodes")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.fontWeight(.bold)
|
||||||
|
}
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if isRefetching {
|
||||||
|
ProgressView()
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Text("No episodes Found:")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button(action: {
|
||||||
|
isRefetching = true
|
||||||
|
fetchDetails()
|
||||||
|
}) {
|
||||||
|
Text("Retry")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarTitle("")
|
||||||
|
.navigationViewStyle(StackNavigationViewStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
buttonRefreshTrigger.toggle()
|
||||||
|
|
||||||
|
if !hasFetched {
|
||||||
|
DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
|
||||||
|
fetchDetails()
|
||||||
|
|
||||||
|
if let savedID = UserDefaults.standard.object(forKey: "custom_anilist_id_\(href)") as? Int {
|
||||||
|
customAniListID = savedID
|
||||||
|
itemID = savedID
|
||||||
|
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
|
||||||
|
} else {
|
||||||
|
fetchItemID(byTitle: cleanTitle(title)) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let id):
|
||||||
|
itemID = id
|
||||||
|
case .failure(let error):
|
||||||
|
Logger.shared.log("Failed to fetch AniList ID: \(error)")
|
||||||
|
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"])
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
hasFetched = true
|
||||||
.navigationBarTitle("")
|
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
|
||||||
.navigationViewStyle(StackNavigationViewStyle())
|
}
|
||||||
|
selectedRange = 0..<episodeChunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if showStreamLoadingView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Loading \(currentStreamTitle)…")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
|
||||||
|
activeFetchID = nil
|
||||||
|
isFetchingEpisode = false
|
||||||
|
showStreamLoadingView = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.background(
|
||||||
|
// Hex #FF705E
|
||||||
|
Color(red: 1.0, green: 112/255.0, blue: 94/255.0)
|
||||||
|
)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.cornerRadius(16)
|
||||||
|
.shadow(color: Color.black.opacity(0.3), radius: 12, x: 0, y: 8)
|
||||||
|
.frame(maxWidth: 300)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: showStreamLoadingView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button(action: {
|
||||||
|
activeFetchID = nil
|
||||||
|
isFetchingEpisode = false
|
||||||
|
showStreamLoadingView = false
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
Text("Search")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onDisappear {
|
||||||
buttonRefreshTrigger.toggle()
|
activeFetchID = nil
|
||||||
|
isFetchingEpisode = false
|
||||||
if !hasFetched {
|
showStreamLoadingView = false
|
||||||
DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
|
|
||||||
fetchDetails()
|
|
||||||
|
|
||||||
if let savedID = UserDefaults.standard.object(forKey: "custom_anilist_id_\(href)") as? Int {
|
|
||||||
customAniListID = savedID
|
|
||||||
itemID = savedID
|
|
||||||
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
|
|
||||||
} else {
|
|
||||||
fetchItemID(byTitle: cleanTitle(title)) { result in
|
|
||||||
switch result {
|
|
||||||
case .success(let id):
|
|
||||||
itemID = id
|
|
||||||
case .failure(let error):
|
|
||||||
Logger.shared.log("Failed to fetch AniList ID: \(error)")
|
|
||||||
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hasFetched = true
|
|
||||||
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
|
|
||||||
}
|
|
||||||
selectedRange = 0..<episodeChunkSize
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,7 +635,10 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStream(href: String) {
|
func fetchStream(href: String) {
|
||||||
DropManager.shared.showDrop(title: "Fetching Stream", subtitle: "", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath"))
|
let fetchID = UUID()
|
||||||
|
activeFetchID = fetchID
|
||||||
|
currentStreamTitle = "Episode \(selectedEpisodeNumber)"
|
||||||
|
showStreamLoadingView = true
|
||||||
isFetchingEpisode = true
|
isFetchingEpisode = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
Task {
|
Task {
|
||||||
|
|
@ -584,6 +649,8 @@ struct MediaInfoView: View {
|
||||||
if module.metadata.softsub == true {
|
if module.metadata.softsub == true {
|
||||||
if module.metadata.asyncJS == true {
|
if module.metadata.asyncJS == true {
|
||||||
jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true, module: module) { result in
|
jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true, module: module) { result in
|
||||||
|
guard self.activeFetchID == fetchID else { return }
|
||||||
|
|
||||||
if let streams = result.streams, !streams.isEmpty {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
if streams.count > 1 {
|
if streams.count > 1 {
|
||||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||||
|
|
@ -599,6 +666,8 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
} else if module.metadata.streamAsyncJS == true {
|
} else if module.metadata.streamAsyncJS == true {
|
||||||
jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true, module: module) { result in
|
jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true, module: module) { result in
|
||||||
|
guard self.activeFetchID == fetchID else { return }
|
||||||
|
|
||||||
if let streams = result.streams, !streams.isEmpty {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
if streams.count > 1 {
|
if streams.count > 1 {
|
||||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||||
|
|
@ -614,6 +683,8 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in
|
jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in
|
||||||
|
guard self.activeFetchID == fetchID else { return }
|
||||||
|
|
||||||
if let streams = result.streams, !streams.isEmpty {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
if streams.count > 1 {
|
if streams.count > 1 {
|
||||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||||
|
|
@ -631,6 +702,8 @@ struct MediaInfoView: View {
|
||||||
} else {
|
} else {
|
||||||
if module.metadata.asyncJS == true {
|
if module.metadata.asyncJS == true {
|
||||||
jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in
|
jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in
|
||||||
|
guard self.activeFetchID == fetchID else { return }
|
||||||
|
|
||||||
if let streams = result.streams, !streams.isEmpty {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
if streams.count > 1 {
|
if streams.count > 1 {
|
||||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||||
|
|
@ -646,6 +719,8 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
} else if module.metadata.streamAsyncJS == true {
|
} else if module.metadata.streamAsyncJS == true {
|
||||||
jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in
|
jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in
|
||||||
|
guard self.activeFetchID == fetchID else { return }
|
||||||
|
|
||||||
if let streams = result.streams, !streams.isEmpty {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
if streams.count > 1 {
|
if streams.count > 1 {
|
||||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||||
|
|
@ -661,6 +736,8 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in
|
jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in
|
||||||
|
guard self.activeFetchID == fetchID else { return }
|
||||||
|
|
||||||
if let streams = result.streams, !streams.isEmpty {
|
if let streams = result.streams, !streams.isEmpty {
|
||||||
if streams.count > 1 {
|
if streams.count > 1 {
|
||||||
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
|
||||||
|
|
@ -687,6 +764,8 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStreamFailure(error: Error? = nil) {
|
func handleStreamFailure(error: Error? = nil) {
|
||||||
|
self.isFetchingEpisode = false
|
||||||
|
self.showStreamLoadingView = false
|
||||||
if let error = error {
|
if let error = error {
|
||||||
Logger.shared.log("Error loading module: \(error)", type: "Error")
|
Logger.shared.log("Error loading module: \(error)", type: "Error")
|
||||||
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"])
|
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"])
|
||||||
|
|
@ -698,6 +777,8 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
func showStreamSelectionAlert(streams: [String], fullURL: String, subtitles: String? = nil) {
|
func showStreamSelectionAlert(streams: [String], fullURL: String, subtitles: String? = nil) {
|
||||||
|
self.isFetchingEpisode = false
|
||||||
|
self.showStreamLoadingView = false
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
|
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
|
||||||
|
|
||||||
|
|
@ -760,6 +841,8 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
func playStream(url: String, fullURL: String, subtitles: String? = nil) {
|
func playStream(url: String, fullURL: String, subtitles: String? = nil) {
|
||||||
|
self.isFetchingEpisode = false
|
||||||
|
self.showStreamLoadingView = false
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora"
|
let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora"
|
||||||
var scheme: String?
|
var scheme: String?
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ struct SearchItem: Identifiable {
|
||||||
let href: String
|
let href: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct SearchView: View {
|
struct SearchView: View {
|
||||||
@AppStorage("selectedModuleId") private var selectedModuleId: String?
|
@AppStorage("selectedModuleId") private var selectedModuleId: String?
|
||||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ struct SettingsViewPlayer: View {
|
||||||
@AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false
|
@AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false
|
||||||
@AppStorage("skip85Visible") private var skip85Visible: Bool = true
|
@AppStorage("skip85Visible") private var skip85Visible: Bool = true
|
||||||
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
|
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
|
||||||
|
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true
|
||||||
|
|
||||||
private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"]
|
private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"]
|
||||||
|
|
||||||
|
|
@ -61,6 +61,28 @@ struct SettingsViewPlayer: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Progress bar Marker Color")) {
|
||||||
|
ColorPicker("Segments Color", selection: Binding(
|
||||||
|
get: {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: "segmentsColorData"),
|
||||||
|
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
|
||||||
|
return Color(uiColor)
|
||||||
|
}
|
||||||
|
return .yellow
|
||||||
|
},
|
||||||
|
set: { newColor in
|
||||||
|
let uiColor = UIColor(newColor)
|
||||||
|
if let data = try? NSKeyedArchiver.archivedData(
|
||||||
|
withRootObject: uiColor,
|
||||||
|
requiringSecureCoding: false
|
||||||
|
) {
|
||||||
|
UserDefaults.standard.set(data, forKey: "segmentsColorData")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
Section(header: Text("Skip Settings"), footer : Text("Double tapping the screen on it's sides will skip with the short tap setting.")) {
|
Section(header: Text("Skip Settings"), footer : Text("Double tapping the screen on it's sides will skip with the short tap setting.")) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Tap Skip:")
|
Text("Tap Skip:")
|
||||||
|
|
@ -79,6 +101,9 @@ struct SettingsViewPlayer: View {
|
||||||
|
|
||||||
Toggle("Show Skip 85s Button", isOn: $skip85Visible)
|
Toggle("Show Skip 85s Button", isOn: $skip85Visible)
|
||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
|
|
||||||
|
Toggle("Show Skip Intro / Outro Buttons", isOn: $skipIntroOutroVisible)
|
||||||
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
SubtitleSettingsSection()
|
SubtitleSettingsSection()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
public-build/Sulfur.ipa
Normal file
BIN
public-build/Sulfur.ipa
Normal file
Binary file not shown.
Loading…
Reference in a new issue