feat: revamp iOS share extension UX and improve Prime URL parsing
- redesign Share Extension with dark streaming-inspired layout - add dynamic half-star rating interaction with stronger haptics - improve dismiss behavior and comments composer UX - support app.primevideo.com share links via gti parsing
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
final class ShareViewController: UIViewController {
|
final class ShareViewController: UIViewController, UITextViewDelegate {
|
||||||
|
|
||||||
// MARK: – View state
|
|
||||||
|
|
||||||
private enum ViewState {
|
private enum ViewState {
|
||||||
case loading
|
case loading
|
||||||
@@ -11,59 +9,97 @@ final class ShareViewController: UIViewController {
|
|||||||
case error(String)
|
case error(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Header
|
private struct CommentItem {
|
||||||
|
let user: String
|
||||||
|
let body: String
|
||||||
|
let time: String
|
||||||
|
}
|
||||||
|
|
||||||
private let headerView = UIView()
|
private var selectedRating = 0.0
|
||||||
private let headerLabel = UILabel()
|
private var starButtons: [UIButton] = []
|
||||||
private let closeButton = UIButton(type: .system)
|
private var comments: [CommentItem] = []
|
||||||
|
|
||||||
// MARK: – Loading / error
|
private let headerView = UIView()
|
||||||
|
private let headerLabel = UILabel()
|
||||||
|
private let closeButton = UIButton(type: .system)
|
||||||
|
|
||||||
private let spinner = UIActivityIndicatorView(style: .large)
|
private let overlayView = UIView()
|
||||||
private let messageLabel = UILabel()
|
private let spinner = UIActivityIndicatorView(style: .large)
|
||||||
|
private let overlayLabel = UILabel()
|
||||||
|
|
||||||
// MARK: – Metadata card (genişletilebilir — buraya puan/yorum ekleyebilirsin)
|
private let scrollView = UIScrollView()
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let backdropContainer = UIView()
|
||||||
/// Bu stack'e ileride yıldız seçici, yorum alanı vb. ekleyebilirsin.
|
private let backdropImageView = UIImageView()
|
||||||
let contentStack = UIStackView()
|
private let gradientLayer = CAGradientLayer()
|
||||||
|
private let providerBadge = UILabel()
|
||||||
|
private let heroTitleLabel = UILabel()
|
||||||
|
private let heroMetaLabel = UILabel()
|
||||||
|
|
||||||
private let providerLabel = UILabel()
|
private let genreScroll = UIScrollView()
|
||||||
private let titleLabel = UILabel()
|
private let genreStack = UIStackView()
|
||||||
private let metaLabel = UILabel()
|
private let plotLabel = UILabel()
|
||||||
private let genresLabel = UILabel()
|
private let castLabel = UILabel()
|
||||||
private let plotLabel = UILabel()
|
|
||||||
|
|
||||||
// MARK: – Lifecycle
|
private let commentsListStack = UIStackView()
|
||||||
|
private let commentTextView = UITextView()
|
||||||
|
private let commentPlaceholderLabel = UILabel()
|
||||||
|
private let submitCommentButton = UIButton(type: .system)
|
||||||
|
private let starsRow = UIStackView()
|
||||||
|
|
||||||
|
private let hoverHaptic = UIImpactFeedbackGenerator(style: .light)
|
||||||
|
private var dismissPanStartTransform: CGAffineTransform = .identity
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .systemBackground
|
overrideUserInterfaceStyle = .dark
|
||||||
|
// Prevent system pull-down dismiss that only closes this extension UI.
|
||||||
|
// We'll handle downward dismiss ourselves and always call completeRequest.
|
||||||
|
isModalInPresentation = true
|
||||||
|
view.backgroundColor = UIColor(red: 0.04, green: 0.04, blue: 0.06, alpha: 1)
|
||||||
|
|
||||||
setupHeader()
|
setupHeader()
|
||||||
setupLoadingViews()
|
|
||||||
setupScrollView()
|
setupScrollView()
|
||||||
|
setupOverlay()
|
||||||
|
setupFeedback()
|
||||||
|
|
||||||
Task { await handleIncomingShare() }
|
Task { await handleIncomingShare() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Setup
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
gradientLayer.frame = backdropContainer.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupFeedback() {
|
||||||
|
hoverHaptic.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
private func setupHeader() {
|
private func setupHeader() {
|
||||||
headerView.backgroundColor = .systemBackground
|
headerView.backgroundColor = UIColor(red: 0.06, green: 0.06, blue: 0.08, alpha: 0.96)
|
||||||
headerView.translatesAutoresizingMaskIntoConstraints = false
|
headerView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(headerView)
|
view.addSubview(headerView)
|
||||||
|
|
||||||
headerLabel.text = "Ratebubble"
|
headerLabel.text = "Ratebubble"
|
||||||
headerLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
headerLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
headerLabel.textColor = .white
|
||||||
headerLabel.translatesAutoresizingMaskIntoConstraints = false
|
headerLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
headerView.addSubview(headerLabel)
|
headerView.addSubview(headerLabel)
|
||||||
|
|
||||||
closeButton.setTitle("Kapat", for: .normal)
|
closeButton.setTitle("Kapat", for: .normal)
|
||||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
closeButton.setTitleColor(.systemGray2, for: .normal)
|
||||||
|
closeButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||||
headerView.addSubview(closeButton)
|
headerView.addSubview(closeButton)
|
||||||
|
|
||||||
|
let dismissPan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan(_:)))
|
||||||
|
dismissPan.maximumNumberOfTouches = 1
|
||||||
|
headerView.addGestureRecognizer(dismissPan)
|
||||||
|
|
||||||
let separator = UIView()
|
let separator = UIView()
|
||||||
separator.backgroundColor = .separator
|
separator.backgroundColor = UIColor.white.withAlphaComponent(0.08)
|
||||||
separator.translatesAutoresizingMaskIntoConstraints = false
|
separator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
headerView.addSubview(separator)
|
headerView.addSubview(separator)
|
||||||
|
|
||||||
@@ -71,7 +107,7 @@ final class ShareViewController: UIViewController {
|
|||||||
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
headerView.heightAnchor.constraint(equalToConstant: 44),
|
headerView.heightAnchor.constraint(equalToConstant: 46),
|
||||||
|
|
||||||
headerLabel.centerXAnchor.constraint(equalTo: headerView.centerXAnchor),
|
headerLabel.centerXAnchor.constraint(equalTo: headerView.centerXAnchor),
|
||||||
headerLabel.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
|
headerLabel.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
|
||||||
@@ -82,156 +118,592 @@ final class ShareViewController: UIViewController {
|
|||||||
separator.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
|
separator.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
|
||||||
separator.trailingAnchor.constraint(equalTo: headerView.trailingAnchor),
|
separator.trailingAnchor.constraint(equalTo: headerView.trailingAnchor),
|
||||||
separator.bottomAnchor.constraint(equalTo: headerView.bottomAnchor),
|
separator.bottomAnchor.constraint(equalTo: headerView.bottomAnchor),
|
||||||
separator.heightAnchor.constraint(equalToConstant: 0.5),
|
separator.heightAnchor.constraint(equalToConstant: 0.5)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupLoadingViews() {
|
|
||||||
spinner.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
spinner.hidesWhenStopped = true
|
|
||||||
view.addSubview(spinner)
|
|
||||||
|
|
||||||
messageLabel.text = "Analiz ediliyor..."
|
|
||||||
messageLabel.textColor = .secondaryLabel
|
|
||||||
messageLabel.textAlignment = .center
|
|
||||||
messageLabel.numberOfLines = 0
|
|
||||||
messageLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(messageLabel)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
||||||
spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -16),
|
|
||||||
messageLabel.topAnchor.constraint(equalTo: spinner.bottomAnchor, constant: 12),
|
|
||||||
messageLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
|
||||||
messageLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
|
||||||
])
|
|
||||||
|
|
||||||
spinner.startAnimating()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupScrollView() {
|
private func setupScrollView() {
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
scrollView.alwaysBounceVertical = true
|
scrollView.alwaysBounceVertical = true
|
||||||
|
scrollView.keyboardDismissMode = .interactive
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
scrollView.isHidden = true
|
scrollView.isHidden = true
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
|
|
||||||
contentStack.axis = .vertical
|
|
||||||
contentStack.spacing = 8
|
|
||||||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
scrollView.addSubview(contentStack)
|
|
||||||
|
|
||||||
// Provider (NETFLIX / PRIME VIDEO)
|
|
||||||
providerLabel.font = .systemFont(ofSize: 11, weight: .bold)
|
|
||||||
|
|
||||||
// Title
|
|
||||||
titleLabel.font = .systemFont(ofSize: 26, weight: .bold)
|
|
||||||
titleLabel.numberOfLines = 3
|
|
||||||
|
|
||||||
// Year · Type · Season
|
|
||||||
metaLabel.font = .systemFont(ofSize: 14)
|
|
||||||
metaLabel.textColor = .secondaryLabel
|
|
||||||
|
|
||||||
// Genres
|
|
||||||
genresLabel.font = .systemFont(ofSize: 13)
|
|
||||||
genresLabel.textColor = .secondaryLabel
|
|
||||||
genresLabel.numberOfLines = 2
|
|
||||||
|
|
||||||
// Plot
|
|
||||||
plotLabel.font = .systemFont(ofSize: 14)
|
|
||||||
plotLabel.numberOfLines = 5
|
|
||||||
plotLabel.textColor = .label
|
|
||||||
|
|
||||||
contentStack.addArrangedSubview(providerLabel)
|
|
||||||
contentStack.addArrangedSubview(titleLabel)
|
|
||||||
contentStack.setCustomSpacing(4, after: providerLabel)
|
|
||||||
contentStack.addArrangedSubview(metaLabel)
|
|
||||||
contentStack.addArrangedSubview(genresLabel)
|
|
||||||
contentStack.setCustomSpacing(12, after: genresLabel)
|
|
||||||
contentStack.addArrangedSubview(plotLabel)
|
|
||||||
|
|
||||||
// ── Buraya ileride yorum/puan UI'ı eklenecek ──
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
scrollView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
|
scrollView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
|
||||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
|
setupBackdrop()
|
||||||
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 20),
|
setupContentStack()
|
||||||
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -20),
|
setupMetadataSection()
|
||||||
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -40),
|
setupRatingSection()
|
||||||
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -32),
|
setupCommentsSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupBackdrop() {
|
||||||
|
backdropContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
backdropContainer.clipsToBounds = true
|
||||||
|
scrollView.addSubview(backdropContainer)
|
||||||
|
|
||||||
|
backdropImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
backdropImageView.contentMode = .scaleAspectFill
|
||||||
|
backdropImageView.backgroundColor = UIColor(red: 0.10, green: 0.10, blue: 0.12, alpha: 1)
|
||||||
|
backdropImageView.alpha = 0
|
||||||
|
backdropContainer.addSubview(backdropImageView)
|
||||||
|
|
||||||
|
gradientLayer.colors = [
|
||||||
|
UIColor.clear.cgColor,
|
||||||
|
UIColor.black.withAlphaComponent(0.40).cgColor,
|
||||||
|
UIColor.black.withAlphaComponent(0.92).cgColor
|
||||||
|
]
|
||||||
|
gradientLayer.locations = [0.25, 0.55, 1.0]
|
||||||
|
backdropContainer.layer.addSublayer(gradientLayer)
|
||||||
|
|
||||||
|
providerBadge.font = .systemFont(ofSize: 10, weight: .bold)
|
||||||
|
providerBadge.textColor = .white
|
||||||
|
providerBadge.layer.cornerRadius = 10
|
||||||
|
providerBadge.layer.masksToBounds = true
|
||||||
|
providerBadge.textAlignment = .center
|
||||||
|
providerBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
backdropContainer.addSubview(providerBadge)
|
||||||
|
|
||||||
|
heroTitleLabel.font = .systemFont(ofSize: 28, weight: .heavy)
|
||||||
|
heroTitleLabel.textColor = .white
|
||||||
|
heroTitleLabel.numberOfLines = 2
|
||||||
|
heroTitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
backdropContainer.addSubview(heroTitleLabel)
|
||||||
|
|
||||||
|
heroMetaLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
heroMetaLabel.textColor = UIColor.white.withAlphaComponent(0.78)
|
||||||
|
heroMetaLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
backdropContainer.addSubview(heroMetaLabel)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
backdropContainer.topAnchor.constraint(equalTo: scrollView.topAnchor),
|
||||||
|
backdropContainer.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
|
||||||
|
backdropContainer.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
|
||||||
|
backdropContainer.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
|
||||||
|
backdropContainer.heightAnchor.constraint(equalToConstant: 248),
|
||||||
|
|
||||||
|
backdropImageView.topAnchor.constraint(equalTo: backdropContainer.topAnchor),
|
||||||
|
backdropImageView.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor),
|
||||||
|
backdropImageView.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor),
|
||||||
|
backdropImageView.bottomAnchor.constraint(equalTo: backdropContainer.bottomAnchor),
|
||||||
|
|
||||||
|
providerBadge.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
|
||||||
|
providerBadge.bottomAnchor.constraint(equalTo: heroTitleLabel.topAnchor, constant: -10),
|
||||||
|
providerBadge.heightAnchor.constraint(equalToConstant: 20),
|
||||||
|
providerBadge.widthAnchor.constraint(greaterThanOrEqualToConstant: 78),
|
||||||
|
|
||||||
|
heroTitleLabel.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
|
||||||
|
heroTitleLabel.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor, constant: -16),
|
||||||
|
heroTitleLabel.bottomAnchor.constraint(equalTo: heroMetaLabel.topAnchor, constant: -5),
|
||||||
|
|
||||||
|
heroMetaLabel.leadingAnchor.constraint(equalTo: backdropContainer.leadingAnchor, constant: 16),
|
||||||
|
heroMetaLabel.trailingAnchor.constraint(equalTo: backdropContainer.trailingAnchor, constant: -16),
|
||||||
|
heroMetaLabel.bottomAnchor.constraint(equalTo: backdropContainer.bottomAnchor, constant: -16)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – State application
|
private func setupContentStack() {
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = 16
|
||||||
|
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scrollView.addSubview(contentStack)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
contentStack.topAnchor.constraint(equalTo: backdropContainer.bottomAnchor, constant: 14),
|
||||||
|
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 14),
|
||||||
|
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -14),
|
||||||
|
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -28),
|
||||||
|
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -30)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupMetadataSection() {
|
||||||
|
let card = makeSectionCard()
|
||||||
|
let stack = makeCardStack()
|
||||||
|
|
||||||
|
genreScroll.showsHorizontalScrollIndicator = false
|
||||||
|
genreScroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
genreScroll.heightAnchor.constraint(equalToConstant: 32).isActive = true
|
||||||
|
|
||||||
|
genreStack.axis = .horizontal
|
||||||
|
genreStack.spacing = 8
|
||||||
|
genreStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
genreScroll.addSubview(genreStack)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
genreStack.topAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.topAnchor),
|
||||||
|
genreStack.leadingAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.leadingAnchor),
|
||||||
|
genreStack.trailingAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.trailingAnchor),
|
||||||
|
genreStack.bottomAnchor.constraint(equalTo: genreScroll.contentLayoutGuide.bottomAnchor),
|
||||||
|
genreStack.heightAnchor.constraint(equalTo: genreScroll.frameLayoutGuide.heightAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
plotLabel.font = .systemFont(ofSize: 14)
|
||||||
|
plotLabel.textColor = UIColor.white.withAlphaComponent(0.84)
|
||||||
|
plotLabel.numberOfLines = 6
|
||||||
|
|
||||||
|
castLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
||||||
|
castLabel.textColor = UIColor.white.withAlphaComponent(0.66)
|
||||||
|
castLabel.numberOfLines = 2
|
||||||
|
|
||||||
|
stack.addArrangedSubview(genreScroll)
|
||||||
|
stack.addArrangedSubview(plotLabel)
|
||||||
|
stack.addArrangedSubview(castLabel)
|
||||||
|
card.addSubview(stack)
|
||||||
|
pinCardStack(stack, in: card)
|
||||||
|
contentStack.addArrangedSubview(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupRatingSection() {
|
||||||
|
let card = makeSectionCard()
|
||||||
|
let stack = makeCardStack()
|
||||||
|
|
||||||
|
let title = UILabel()
|
||||||
|
title.text = "Puanla"
|
||||||
|
title.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
title.textColor = .white
|
||||||
|
|
||||||
|
let subtitle = UILabel()
|
||||||
|
subtitle.text = "Puanını istediğin zaman değiştirebilirsin."
|
||||||
|
subtitle.font = .systemFont(ofSize: 12, weight: .regular)
|
||||||
|
subtitle.textColor = UIColor.white.withAlphaComponent(0.55)
|
||||||
|
|
||||||
|
starsRow.axis = .horizontal
|
||||||
|
starsRow.alignment = .center
|
||||||
|
starsRow.distribution = .fillEqually
|
||||||
|
starsRow.spacing = 4
|
||||||
|
starsRow.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
starsRow.isUserInteractionEnabled = true
|
||||||
|
starsRow.heightAnchor.constraint(equalToConstant: 48).isActive = true
|
||||||
|
|
||||||
|
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handleStarPan(_:)))
|
||||||
|
panGesture.maximumNumberOfTouches = 1
|
||||||
|
starsRow.addGestureRecognizer(panGesture)
|
||||||
|
|
||||||
|
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 30, weight: .medium)
|
||||||
|
for i in 1...5 {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.setImage(UIImage(systemName: "star", withConfiguration: symbolConfig), for: .normal)
|
||||||
|
button.tintColor = UIColor.white.withAlphaComponent(0.18)
|
||||||
|
button.tag = i
|
||||||
|
button.addTarget(self, action: #selector(starTapped(_:forEvent:)), for: .touchUpInside)
|
||||||
|
button.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
button.heightAnchor.constraint(equalToConstant: 46).isActive = true
|
||||||
|
button.accessibilityLabel = "\(i) yıldız"
|
||||||
|
starButtons.append(button)
|
||||||
|
starsRow.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.addArrangedSubview(title)
|
||||||
|
stack.addArrangedSubview(subtitle)
|
||||||
|
stack.addArrangedSubview(starsRow)
|
||||||
|
|
||||||
|
card.addSubview(stack)
|
||||||
|
pinCardStack(stack, in: card)
|
||||||
|
contentStack.addArrangedSubview(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupCommentsSection() {
|
||||||
|
let card = makeSectionCard()
|
||||||
|
let stack = makeCardStack()
|
||||||
|
|
||||||
|
let title = UILabel()
|
||||||
|
title.text = "Yorumlar"
|
||||||
|
title.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
title.textColor = .white
|
||||||
|
|
||||||
|
commentsListStack.axis = .vertical
|
||||||
|
commentsListStack.spacing = 10
|
||||||
|
commentsListStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let composerContainer = UIView()
|
||||||
|
composerContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
composerContainer.backgroundColor = UIColor.white.withAlphaComponent(0.06)
|
||||||
|
composerContainer.layer.cornerRadius = 14
|
||||||
|
composerContainer.layer.borderWidth = 1
|
||||||
|
composerContainer.layer.borderColor = UIColor.white.withAlphaComponent(0.08).cgColor
|
||||||
|
|
||||||
|
commentTextView.backgroundColor = .clear
|
||||||
|
commentTextView.textColor = .white
|
||||||
|
commentTextView.font = .systemFont(ofSize: 14)
|
||||||
|
commentTextView.tintColor = .systemRed
|
||||||
|
commentTextView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
|
||||||
|
commentTextView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
commentTextView.delegate = self
|
||||||
|
commentTextView.isScrollEnabled = true
|
||||||
|
commentTextView.heightAnchor.constraint(equalToConstant: 96).isActive = true
|
||||||
|
|
||||||
|
commentPlaceholderLabel.text = "Yorumunu yaz..."
|
||||||
|
commentPlaceholderLabel.textColor = UIColor.white.withAlphaComponent(0.38)
|
||||||
|
commentPlaceholderLabel.font = .systemFont(ofSize: 14)
|
||||||
|
commentPlaceholderLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
submitCommentButton.setTitle("Gönder", for: .normal)
|
||||||
|
submitCommentButton.setTitleColor(.white, for: .normal)
|
||||||
|
submitCommentButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||||
|
submitCommentButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.92)
|
||||||
|
submitCommentButton.layer.cornerRadius = 10
|
||||||
|
submitCommentButton.contentEdgeInsets = UIEdgeInsets(top: 10, left: 18, bottom: 10, right: 18)
|
||||||
|
submitCommentButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
submitCommentButton.addTarget(self, action: #selector(submitCommentTapped), for: .touchUpInside)
|
||||||
|
submitCommentButton.isEnabled = false
|
||||||
|
submitCommentButton.alpha = 0.5
|
||||||
|
|
||||||
|
composerContainer.addSubview(commentTextView)
|
||||||
|
composerContainer.addSubview(commentPlaceholderLabel)
|
||||||
|
composerContainer.addSubview(submitCommentButton)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
commentTextView.topAnchor.constraint(equalTo: composerContainer.topAnchor),
|
||||||
|
commentTextView.leadingAnchor.constraint(equalTo: composerContainer.leadingAnchor),
|
||||||
|
commentTextView.trailingAnchor.constraint(equalTo: composerContainer.trailingAnchor),
|
||||||
|
|
||||||
|
commentPlaceholderLabel.leadingAnchor.constraint(equalTo: commentTextView.leadingAnchor, constant: 14),
|
||||||
|
commentPlaceholderLabel.topAnchor.constraint(equalTo: commentTextView.topAnchor, constant: 12),
|
||||||
|
|
||||||
|
submitCommentButton.topAnchor.constraint(equalTo: commentTextView.bottomAnchor, constant: 8),
|
||||||
|
submitCommentButton.trailingAnchor.constraint(equalTo: composerContainer.trailingAnchor, constant: -10),
|
||||||
|
submitCommentButton.bottomAnchor.constraint(equalTo: composerContainer.bottomAnchor, constant: -10)
|
||||||
|
])
|
||||||
|
|
||||||
|
stack.addArrangedSubview(title)
|
||||||
|
stack.addArrangedSubview(commentsListStack)
|
||||||
|
stack.addArrangedSubview(composerContainer)
|
||||||
|
card.addSubview(stack)
|
||||||
|
pinCardStack(stack, in: card)
|
||||||
|
contentStack.addArrangedSubview(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pinCardStack(_ stack: UIStackView, in card: UIView) {
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
|
||||||
|
stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSectionCard() -> UIView {
|
||||||
|
let card = UIView()
|
||||||
|
card.backgroundColor = UIColor.white.withAlphaComponent(0.05)
|
||||||
|
card.layer.cornerRadius = 16
|
||||||
|
card.layer.borderWidth = 1
|
||||||
|
card.layer.borderColor = UIColor.white.withAlphaComponent(0.07).cgColor
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCardStack() -> UIStackView {
|
||||||
|
let stack = UIStackView()
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return stack
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func apply(_ state: ViewState) {
|
private func apply(_ state: ViewState) {
|
||||||
switch state {
|
switch state {
|
||||||
|
|
||||||
case .loading:
|
case .loading:
|
||||||
spinner.startAnimating()
|
overlayView.isHidden = false
|
||||||
messageLabel.text = "Analiz ediliyor..."
|
|
||||||
messageLabel.isHidden = false
|
|
||||||
scrollView.isHidden = true
|
scrollView.isHidden = true
|
||||||
|
spinner.startAnimating()
|
||||||
|
overlayLabel.text = "İçerik analiz ediliyor..."
|
||||||
|
|
||||||
case .success(let info):
|
case .success(let info):
|
||||||
spinner.stopAnimating()
|
if info.provider == "netflix" {
|
||||||
messageLabel.isHidden = true
|
providerBadge.text = " NETFLIX "
|
||||||
|
providerBadge.backgroundColor = UIColor(red: 0.90, green: 0.11, blue: 0.15, alpha: 0.95)
|
||||||
switch info.provider {
|
} else if info.provider == "primevideo" {
|
||||||
case "netflix":
|
providerBadge.text = " PRIME VIDEO "
|
||||||
providerLabel.text = "NETFLIX"
|
providerBadge.backgroundColor = UIColor(red: 0.05, green: 0.62, blue: 0.90, alpha: 0.95)
|
||||||
providerLabel.textColor = .systemRed
|
} else {
|
||||||
case "primevideo":
|
providerBadge.text = " \(info.provider.uppercased()) "
|
||||||
providerLabel.text = "PRIME VIDEO"
|
providerBadge.backgroundColor = UIColor.white.withAlphaComponent(0.20)
|
||||||
providerLabel.textColor = .systemCyan
|
|
||||||
default:
|
|
||||||
providerLabel.text = info.provider.uppercased()
|
|
||||||
providerLabel.textColor = .secondaryLabel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
titleLabel.text = info.title
|
heroTitleLabel.text = info.title
|
||||||
|
|
||||||
var parts: [String] = []
|
var parts: [String] = []
|
||||||
if let year = info.year { parts.append("\(year)") }
|
if let year = info.year { parts.append("\(year)") }
|
||||||
parts.append(info.type == "movie" ? "Film" : "Dizi")
|
parts.append(info.type == "movie" ? "Film" : "Dizi")
|
||||||
if let season = info.currentSeason { parts.append("Sezon \(season)") }
|
if let season = info.currentSeason { parts.append("Sezon \(season)") }
|
||||||
metaLabel.text = parts.joined(separator: " · ")
|
heroMetaLabel.text = parts.joined(separator: " • ")
|
||||||
|
|
||||||
|
genreStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
if info.genres.isEmpty {
|
if info.genres.isEmpty {
|
||||||
genresLabel.isHidden = true
|
genreScroll.isHidden = true
|
||||||
} else {
|
} else {
|
||||||
genresLabel.text = info.genres.joined(separator: ", ")
|
genreScroll.isHidden = false
|
||||||
genresLabel.isHidden = false
|
info.genres.forEach { genreStack.addArrangedSubview(makeChip($0)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if let plot = info.plot, !plot.isEmpty {
|
if let plot = info.plot, !plot.isEmpty {
|
||||||
plotLabel.text = plot
|
plotLabel.text = plot
|
||||||
plotLabel.isHidden = false
|
plotLabel.isHidden = false
|
||||||
} else {
|
} else {
|
||||||
|
plotLabel.text = nil
|
||||||
plotLabel.isHidden = true
|
plotLabel.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if info.cast.isEmpty {
|
||||||
|
castLabel.text = nil
|
||||||
|
castLabel.isHidden = true
|
||||||
|
} else {
|
||||||
|
castLabel.text = "Oyuncular: \(info.cast.prefix(7).joined(separator: ", "))"
|
||||||
|
castLabel.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
seedCommentsIfNeeded(for: info)
|
||||||
|
renderComments()
|
||||||
|
|
||||||
|
if let urlString = info.backdrop, let imageURL = URL(string: urlString) {
|
||||||
|
Task {
|
||||||
|
guard let (data, _) = try? await URLSession.shared.data(from: imageURL),
|
||||||
|
let image = UIImage(data: data) else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
self.backdropImageView.image = image
|
||||||
|
UIView.animate(withDuration: 0.35) {
|
||||||
|
self.backdropImageView.alpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
backdropImageView.image = nil
|
||||||
|
backdropImageView.alpha = 0
|
||||||
|
}
|
||||||
|
|
||||||
scrollView.isHidden = false
|
scrollView.isHidden = false
|
||||||
|
spinner.stopAnimating()
|
||||||
|
UIView.animate(withDuration: 0.20, animations: {
|
||||||
|
self.overlayView.alpha = 0
|
||||||
|
}, completion: { _ in
|
||||||
|
self.overlayView.isHidden = true
|
||||||
|
self.overlayView.alpha = 1
|
||||||
|
})
|
||||||
|
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
spinner.stopAnimating()
|
overlayView.isHidden = false
|
||||||
messageLabel.text = message
|
|
||||||
messageLabel.isHidden = false
|
|
||||||
scrollView.isHidden = true
|
scrollView.isHidden = true
|
||||||
|
spinner.stopAnimating()
|
||||||
|
overlayLabel.text = message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Share handling
|
private func makeChip(_ text: String) -> UIView {
|
||||||
|
var cfg = UIButton.Configuration.filled()
|
||||||
|
cfg.title = text
|
||||||
|
cfg.baseForegroundColor = .white
|
||||||
|
cfg.baseBackgroundColor = UIColor.white.withAlphaComponent(0.10)
|
||||||
|
cfg.cornerStyle = .capsule
|
||||||
|
cfg.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)
|
||||||
|
cfg.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attrs in
|
||||||
|
var attrs = attrs
|
||||||
|
attrs.font = UIFont.systemFont(ofSize: 12, weight: .semibold)
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
let button = UIButton(configuration: cfg)
|
||||||
|
button.isUserInteractionEnabled = false
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func starTapped(_ sender: UIButton, forEvent event: UIEvent?) {
|
||||||
|
let touchLocation = event?.allTouches?.first?.location(in: sender) ?? CGPoint(x: sender.bounds.midX, y: sender.bounds.midY)
|
||||||
|
let isLeftHalf = touchLocation.x < sender.bounds.midX
|
||||||
|
let rating = Double(sender.tag - 1) + (isLeftHalf ? 0.5 : 1.0)
|
||||||
|
updateRating(to: rating, animatedFrom: sender, withHaptic: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleStarPan(_ gesture: UIPanGestureRecognizer) {
|
||||||
|
let point = gesture.location(in: starsRow)
|
||||||
|
guard starsRow.bounds.width > 0 else { return }
|
||||||
|
|
||||||
|
switch gesture.state {
|
||||||
|
case .began:
|
||||||
|
hoverHaptic.prepare()
|
||||||
|
fallthrough
|
||||||
|
case .changed:
|
||||||
|
let clampedX = min(max(point.x, 0), starsRow.bounds.width - 0.001)
|
||||||
|
let ratio = clampedX / starsRow.bounds.width
|
||||||
|
let starCount = Double(max(starButtons.count, 1))
|
||||||
|
let rawValue = ratio * starCount
|
||||||
|
let halfStepped = max(0.5, min(starCount, (rawValue * 2).rounded(.up) / 2))
|
||||||
|
let value = halfStepped
|
||||||
|
updateRating(to: value, animatedFrom: nil, withHaptic: true)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRating(to newValue: Double, animatedFrom sourceButton: UIButton?, withHaptic: Bool) {
|
||||||
|
let maxRating = Double(starButtons.count)
|
||||||
|
let clamped = min(max(newValue, 0.5), maxRating)
|
||||||
|
guard abs(clamped - selectedRating) > 0.001 else { return }
|
||||||
|
selectedRating = clamped
|
||||||
|
refreshStars(animatedFrom: sourceButton)
|
||||||
|
if withHaptic {
|
||||||
|
hoverHaptic.impactOccurred(intensity: 1.0)
|
||||||
|
hoverHaptic.prepare()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshStars(animatedFrom sourceButton: UIButton? = nil) {
|
||||||
|
let config = UIImage.SymbolConfiguration(pointSize: 30, weight: .medium)
|
||||||
|
for button in starButtons {
|
||||||
|
let buttonValue = Double(button.tag)
|
||||||
|
let imageName: String
|
||||||
|
if selectedRating >= buttonValue {
|
||||||
|
imageName = "star.fill"
|
||||||
|
} else if selectedRating >= (buttonValue - 0.5) {
|
||||||
|
imageName = "star.leadinghalf.filled"
|
||||||
|
} else {
|
||||||
|
imageName = "star"
|
||||||
|
}
|
||||||
|
|
||||||
|
button.setImage(UIImage(systemName: imageName, withConfiguration: config), for: .normal)
|
||||||
|
button.tintColor = imageName == "star" ? UIColor.white.withAlphaComponent(0.18) : UIColor(red: 0.96, green: 0.74, blue: 0.20, alpha: 1.0)
|
||||||
|
|
||||||
|
let isActive = (selectedRating >= (buttonValue - 0.5))
|
||||||
|
if isActive && sourceButton === button {
|
||||||
|
UIView.animate(withDuration: 0.10, animations: {
|
||||||
|
button.transform = CGAffineTransform(scaleX: 1.18, y: 1.18)
|
||||||
|
}, completion: { _ in
|
||||||
|
UIView.animate(withDuration: 0.10) {
|
||||||
|
button.transform = .identity
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupOverlay() {
|
||||||
|
overlayView.backgroundColor = UIColor(red: 0.04, green: 0.04, blue: 0.06, alpha: 0.96)
|
||||||
|
overlayView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(overlayView)
|
||||||
|
|
||||||
|
spinner.color = .white
|
||||||
|
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
spinner.hidesWhenStopped = true
|
||||||
|
overlayView.addSubview(spinner)
|
||||||
|
|
||||||
|
overlayLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
|
overlayLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
||||||
|
overlayLabel.textAlignment = .center
|
||||||
|
overlayLabel.numberOfLines = 0
|
||||||
|
overlayLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
overlayView.addSubview(overlayLabel)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
overlayView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
|
||||||
|
overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
|
spinner.centerXAnchor.constraint(equalTo: overlayView.centerXAnchor),
|
||||||
|
spinner.centerYAnchor.constraint(equalTo: overlayView.centerYAnchor, constant: -18),
|
||||||
|
|
||||||
|
overlayLabel.topAnchor.constraint(equalTo: spinner.bottomAnchor, constant: 12),
|
||||||
|
overlayLabel.leadingAnchor.constraint(equalTo: overlayView.leadingAnchor, constant: 24),
|
||||||
|
overlayLabel.trailingAnchor.constraint(equalTo: overlayView.trailingAnchor, constant: -24)
|
||||||
|
])
|
||||||
|
|
||||||
|
spinner.startAnimating()
|
||||||
|
overlayLabel.text = "İçerik analiz ediliyor..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func seedCommentsIfNeeded(for info: GetInfoResponse) {
|
||||||
|
guard comments.isEmpty else { return }
|
||||||
|
comments = [
|
||||||
|
CommentItem(user: "deniz", body: "Görüntü yönetimi çok iyi, finali de güçlüydü.", time: "2 saat önce"),
|
||||||
|
CommentItem(user: "melis", body: "Tempo bazı bölümlerde düşüyor ama genel olarak keyifli.", time: "5 saat önce"),
|
||||||
|
CommentItem(user: "arda", body: "\(info.title) için müzik seçimleri efsane olmuş.", time: "Dün")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func renderComments() {
|
||||||
|
commentsListStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
for item in comments {
|
||||||
|
commentsListStack.addArrangedSubview(makeCommentBubble(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCommentBubble(_ item: CommentItem) -> UIView {
|
||||||
|
let bubble = UIView()
|
||||||
|
bubble.backgroundColor = UIColor.white.withAlphaComponent(0.07)
|
||||||
|
bubble.layer.cornerRadius = 12
|
||||||
|
bubble.layer.borderWidth = 1
|
||||||
|
bubble.layer.borderColor = UIColor.white.withAlphaComponent(0.06).cgColor
|
||||||
|
|
||||||
|
let userLabel = UILabel()
|
||||||
|
userLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||||||
|
userLabel.textColor = .white
|
||||||
|
userLabel.text = "@\(item.user)"
|
||||||
|
userLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let bodyLabel = UILabel()
|
||||||
|
bodyLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
||||||
|
bodyLabel.textColor = UIColor.white.withAlphaComponent(0.83)
|
||||||
|
bodyLabel.numberOfLines = 0
|
||||||
|
bodyLabel.text = item.body
|
||||||
|
bodyLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let timeLabel = UILabel()
|
||||||
|
timeLabel.font = .systemFont(ofSize: 11, weight: .regular)
|
||||||
|
timeLabel.textColor = UIColor.white.withAlphaComponent(0.50)
|
||||||
|
timeLabel.text = item.time
|
||||||
|
timeLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
bubble.addSubview(userLabel)
|
||||||
|
bubble.addSubview(bodyLabel)
|
||||||
|
bubble.addSubview(timeLabel)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
userLabel.topAnchor.constraint(equalTo: bubble.topAnchor, constant: 10),
|
||||||
|
userLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
|
||||||
|
userLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
|
||||||
|
|
||||||
|
bodyLabel.topAnchor.constraint(equalTo: userLabel.bottomAnchor, constant: 6),
|
||||||
|
bodyLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
|
||||||
|
bodyLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
|
||||||
|
|
||||||
|
timeLabel.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: 8),
|
||||||
|
timeLabel.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: 12),
|
||||||
|
timeLabel.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -12),
|
||||||
|
timeLabel.bottomAnchor.constraint(equalTo: bubble.bottomAnchor, constant: -10)
|
||||||
|
])
|
||||||
|
|
||||||
|
return bubble
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func submitCommentTapped() {
|
||||||
|
let text = commentTextView.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
|
||||||
|
comments.insert(CommentItem(user: "sen", body: text, time: "Şimdi"), at: 0)
|
||||||
|
commentTextView.text = ""
|
||||||
|
textViewDidChange(commentTextView)
|
||||||
|
renderComments()
|
||||||
|
hoverHaptic.impactOccurred(intensity: 0.35)
|
||||||
|
hoverHaptic.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
commentPlaceholderLabel.isHidden = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
let hasText = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
submitCommentButton.alpha = hasText ? 1.0 : 0.5
|
||||||
|
submitCommentButton.isEnabled = hasText
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleIncomingShare() async {
|
private func handleIncomingShare() async {
|
||||||
|
apply(.loading)
|
||||||
|
|
||||||
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
|
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
|
||||||
let providers = items.flatMap { $0.attachments ?? [] }
|
let providers = items.flatMap { $0.attachments ?? [] }
|
||||||
|
|
||||||
guard !providers.isEmpty else {
|
guard !providers.isEmpty else {
|
||||||
apply(.error("Paylaşılan içerik okunamadı."))
|
apply(.error("Paylaşılan içerik okunamadı."))
|
||||||
return
|
return
|
||||||
@@ -239,14 +711,13 @@ final class ShareViewController: UIViewController {
|
|||||||
|
|
||||||
for provider in providers {
|
for provider in providers {
|
||||||
if let extracted = await extractURL(from: provider), isSupportedStreamingURL(extracted) {
|
if let extracted = await extractURL(from: provider), isSupportedStreamingURL(extracted) {
|
||||||
// Ana uygulama arka planda açılırsa URL'i görmesi için sakla
|
let normalized = normalizeURL(extracted)
|
||||||
SharedPayloadStore.saveIncomingURL(extracted.absoluteString)
|
SharedPayloadStore.saveIncomingURL(normalized.absoluteString)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let info = try await APIClient.shared.getInfo(url: extracted.absoluteString)
|
let info = try await APIClient.shared.getInfo(url: normalized.absoluteString)
|
||||||
apply(.success(info))
|
apply(.success(info))
|
||||||
} catch {
|
} catch {
|
||||||
apply(.error("Bilgiler alınamadı:\n\(error.localizedDescription)"))
|
apply(.error("Bilgiler alınamadı.\n\(error.localizedDescription)"))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -255,13 +726,37 @@ final class ShareViewController: UIViewController {
|
|||||||
apply(.error("Geçerli bir Netflix/Prime Video linki bulunamadı."))
|
apply(.error("Geçerli bir Netflix/Prime Video linki bulunamadı."))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Actions
|
|
||||||
|
|
||||||
@objc private func closeTapped() {
|
@objc private func closeTapped() {
|
||||||
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – URL helpers
|
@objc private func handleDismissPan(_ gesture: UIPanGestureRecognizer) {
|
||||||
|
let translation = gesture.translation(in: view)
|
||||||
|
let velocity = gesture.velocity(in: view)
|
||||||
|
|
||||||
|
switch gesture.state {
|
||||||
|
case .began:
|
||||||
|
dismissPanStartTransform = headerView.transform
|
||||||
|
case .changed:
|
||||||
|
let downY = max(0, translation.y)
|
||||||
|
let progress = min(downY / 140.0, 1.0)
|
||||||
|
headerView.transform = dismissPanStartTransform.translatedBy(x: 0, y: downY * 0.3)
|
||||||
|
headerView.alpha = 1.0 - (progress * 0.25)
|
||||||
|
case .ended, .cancelled, .failed:
|
||||||
|
let shouldDismiss = translation.y > 90 || velocity.y > 900
|
||||||
|
if shouldDismiss {
|
||||||
|
closeTapped()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.18) {
|
||||||
|
self.headerView.transform = .identity
|
||||||
|
self.headerView.alpha = 1.0
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func extractURL(from provider: NSItemProvider) async -> URL? {
|
private func extractURL(from provider: NSItemProvider) async -> URL? {
|
||||||
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||||
@@ -295,13 +790,24 @@ final class ShareViewController: UIViewController {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func normalizeURL(_ url: URL) -> URL {
|
||||||
|
let host = url.host?.lowercased() ?? ""
|
||||||
|
guard host == "app.primevideo.com" else { return url }
|
||||||
|
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
if let gti = components?.queryItems?.first(where: { $0.name == "gti" }) {
|
||||||
|
components?.queryItems = [gti]
|
||||||
|
} else {
|
||||||
|
components?.queryItems = nil
|
||||||
|
}
|
||||||
|
return components?.url ?? url
|
||||||
|
}
|
||||||
|
|
||||||
private func isSupportedStreamingURL(_ url: URL) -> Bool {
|
private func isSupportedStreamingURL(_ url: URL) -> Bool {
|
||||||
let host = url.host?.lowercased() ?? ""
|
let host = url.host?.lowercased() ?? ""
|
||||||
let netflixHosts = ["www.netflix.com", "netflix.com", "www.netflix.com.tr", "netflix.com.tr"]
|
let netflixHosts = ["www.netflix.com", "netflix.com", "www.netflix.com.tr", "netflix.com.tr"]
|
||||||
let primeHosts = ["www.primevideo.com", "primevideo.com", "www.amazon.com", "amazon.com"]
|
let primeHosts = ["www.primevideo.com", "primevideo.com", "www.amazon.com", "amazon.com", "app.primevideo.com"]
|
||||||
|
|
||||||
guard netflixHosts.contains(host) || primeHosts.contains(host) else { return false }
|
guard netflixHosts.contains(host) || primeHosts.contains(host) else { return false }
|
||||||
|
|
||||||
let path = url.path.lowercased()
|
let path = url.path.lowercased()
|
||||||
if path.contains("/title/") || path.contains("/watch/") || path.contains("/detail/") {
|
if path.contains("/title/") || path.contains("/watch/") || path.contains("/detail/") {
|
||||||
return true
|
return true
|
||||||
@@ -311,11 +817,12 @@ final class ShareViewController: UIViewController {
|
|||||||
|
|
||||||
private static func firstURL(in raw: String) -> URL? {
|
private static func firstURL(in raw: String) -> URL? {
|
||||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let url = URL(string: text), url.scheme?.isEmpty == false { return url }
|
if let url = URL(string: text), url.scheme?.isEmpty == false {
|
||||||
|
return url
|
||||||
|
}
|
||||||
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
return detector.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text))?.url
|
||||||
return detector.firstMatch(in: text, options: [], range: range)?.url
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const NETFLIX_HOSTS = new Set([
|
|||||||
const PRIME_HOSTS = new Set([
|
const PRIME_HOSTS = new Set([
|
||||||
'www.primevideo.com',
|
'www.primevideo.com',
|
||||||
'primevideo.com',
|
'primevideo.com',
|
||||||
|
'app.primevideo.com', // iOS uygulama paylaşım linkleri
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export interface ParsedContentUrl {
|
export interface ParsedContentUrl {
|
||||||
@@ -31,11 +32,20 @@ export function parseSupportedContentUrl(rawUrl: string): ParsedContentUrl | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (PRIME_HOSTS.has(hostname)) {
|
if (PRIME_HOSTS.has(hostname)) {
|
||||||
const detailIdMatch = parsedUrl.pathname.match(/\/detail\/([A-Za-z0-9]+)/);
|
// Standart web URL: /detail/TITLE/ID veya /-/tr/detail/ID
|
||||||
if (!detailIdMatch) return null;
|
// GTI formatı nokta ve tire içerebilir: amzn1.dv.gti.UUID
|
||||||
const id = detailIdMatch[1];
|
const detailIdMatch = parsedUrl.pathname.match(/\/detail\/([A-Za-z0-9._-]+)/);
|
||||||
if (!id) return null;
|
if (detailIdMatch?.[1]) {
|
||||||
return { provider: 'primevideo', id };
|
return { provider: 'primevideo', id: detailIdMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS uygulama paylaşım linki: /detail?gti=amzn1.dv.gti.UUID
|
||||||
|
if (parsedUrl.pathname === '/detail') {
|
||||||
|
const gti = parsedUrl.searchParams.get('gti');
|
||||||
|
if (gti) return { provider: 'primevideo', id: gti };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user