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:
2026-03-03 23:46:01 +03:00
parent 8bd4f24774
commit d268bc5696
2 changed files with 658 additions and 141 deletions

View File

@@ -1,9 +1,7 @@
import UIKit
import UniformTypeIdentifiers
final class ShareViewController: UIViewController {
// MARK: View state
final class ShareViewController: UIViewController, UITextViewDelegate {
private enum ViewState {
case loading
@@ -11,59 +9,97 @@ final class ShareViewController: UIViewController {
case error(String)
}
// MARK: Header
private struct CommentItem {
let user: String
let body: String
let time: String
}
private let headerView = UIView()
private let headerLabel = UILabel()
private let closeButton = UIButton(type: .system)
private var selectedRating = 0.0
private var starButtons: [UIButton] = []
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 messageLabel = UILabel()
private let overlayView = UIView()
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()
/// Bu stack'e ileride yıldız seçici, yorum alanı vb. ekleyebilirsin.
let contentStack = UIStackView()
private let backdropContainer = UIView()
private let backdropImageView = UIImageView()
private let gradientLayer = CAGradientLayer()
private let providerBadge = UILabel()
private let heroTitleLabel = UILabel()
private let heroMetaLabel = UILabel()
private let providerLabel = UILabel()
private let titleLabel = UILabel()
private let metaLabel = UILabel()
private let genresLabel = UILabel()
private let plotLabel = UILabel()
private let genreScroll = UIScrollView()
private let genreStack = UIStackView()
private let plotLabel = UILabel()
private let castLabel = 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() {
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()
setupLoadingViews()
setupScrollView()
setupOverlay()
setupFeedback()
Task { await handleIncomingShare() }
}
// MARK: Setup
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
gradientLayer.frame = backdropContainer.bounds
}
private func setupFeedback() {
hoverHaptic.prepare()
}
private func setupHeader() {
headerView.backgroundColor = .systemBackground
headerView.backgroundColor = UIColor(red: 0.06, green: 0.06, blue: 0.08, alpha: 0.96)
headerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(headerView)
headerLabel.text = "Ratebubble"
headerLabel.font = .systemFont(ofSize: 17, weight: .semibold)
headerLabel.textColor = .white
headerLabel.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(headerLabel)
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.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
headerView.addSubview(closeButton)
let dismissPan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan(_:)))
dismissPan.maximumNumberOfTouches = 1
headerView.addGestureRecognizer(dismissPan)
let separator = UIView()
separator.backgroundColor = .separator
separator.backgroundColor = UIColor.white.withAlphaComponent(0.08)
separator.translatesAutoresizingMaskIntoConstraints = false
headerView.addSubview(separator)
@@ -71,7 +107,7 @@ final class ShareViewController: UIViewController {
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
headerView.heightAnchor.constraint(equalToConstant: 44),
headerView.heightAnchor.constraint(equalToConstant: 46),
headerLabel.centerXAnchor.constraint(equalTo: headerView.centerXAnchor),
headerLabel.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
@@ -82,156 +118,592 @@ final class ShareViewController: UIViewController {
separator.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
separator.trailingAnchor.constraint(equalTo: headerView.trailingAnchor),
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() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = true
scrollView.keyboardDismissMode = .interactive
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.isHidden = true
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([
scrollView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
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),
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 20),
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -20),
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -40),
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -32),
setupBackdrop()
setupContentStack()
setupMetadataSection()
setupRatingSection()
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
private func apply(_ state: ViewState) {
switch state {
case .loading:
spinner.startAnimating()
messageLabel.text = "Analiz ediliyor..."
messageLabel.isHidden = false
overlayView.isHidden = false
scrollView.isHidden = true
spinner.startAnimating()
overlayLabel.text = "İçerik analiz ediliyor..."
case .success(let info):
spinner.stopAnimating()
messageLabel.isHidden = true
switch info.provider {
case "netflix":
providerLabel.text = "NETFLIX"
providerLabel.textColor = .systemRed
case "primevideo":
providerLabel.text = "PRIME VIDEO"
providerLabel.textColor = .systemCyan
default:
providerLabel.text = info.provider.uppercased()
providerLabel.textColor = .secondaryLabel
if info.provider == "netflix" {
providerBadge.text = " NETFLIX "
providerBadge.backgroundColor = UIColor(red: 0.90, green: 0.11, blue: 0.15, alpha: 0.95)
} else if info.provider == "primevideo" {
providerBadge.text = " PRIME VIDEO "
providerBadge.backgroundColor = UIColor(red: 0.05, green: 0.62, blue: 0.90, alpha: 0.95)
} else {
providerBadge.text = " \(info.provider.uppercased()) "
providerBadge.backgroundColor = UIColor.white.withAlphaComponent(0.20)
}
titleLabel.text = info.title
heroTitleLabel.text = info.title
var parts: [String] = []
if let year = info.year { parts.append("\(year)") }
parts.append(info.type == "movie" ? "Film" : "Dizi")
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 {
genresLabel.isHidden = true
genreScroll.isHidden = true
} else {
genresLabel.text = info.genres.joined(separator: ", ")
genresLabel.isHidden = false
genreScroll.isHidden = false
info.genres.forEach { genreStack.addArrangedSubview(makeChip($0)) }
}
if let plot = info.plot, !plot.isEmpty {
plotLabel.text = plot
plotLabel.isHidden = false
} else {
plotLabel.text = nil
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
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):
spinner.stopAnimating()
messageLabel.text = message
messageLabel.isHidden = false
overlayView.isHidden = false
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
private func handleIncomingShare() async {
apply(.loading)
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
let providers = items.flatMap { $0.attachments ?? [] }
guard !providers.isEmpty else {
apply(.error("Paylaşılan içerik okunamadı."))
return
@@ -239,14 +711,13 @@ final class ShareViewController: UIViewController {
for provider in providers {
if let extracted = await extractURL(from: provider), isSupportedStreamingURL(extracted) {
// Ana uygulama arka planda açılırsa URL'i görmesi için sakla
SharedPayloadStore.saveIncomingURL(extracted.absoluteString)
let normalized = normalizeURL(extracted)
SharedPayloadStore.saveIncomingURL(normalized.absoluteString)
do {
let info = try await APIClient.shared.getInfo(url: extracted.absoluteString)
let info = try await APIClient.shared.getInfo(url: normalized.absoluteString)
apply(.success(info))
} catch {
apply(.error("Bilgiler alınamadı:\n\(error.localizedDescription)"))
apply(.error("Bilgiler alınamadı.\n\(error.localizedDescription)"))
}
return
}
@@ -255,13 +726,37 @@ final class ShareViewController: UIViewController {
apply(.error("Geçerli bir Netflix/Prime Video linki bulunamadı."))
}
// MARK: Actions
@objc private func closeTapped() {
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? {
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
@@ -295,13 +790,24 @@ final class ShareViewController: UIViewController {
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 {
let host = url.host?.lowercased() ?? ""
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 }
let path = url.path.lowercased()
if path.contains("/title/") || path.contains("/watch/") || path.contains("/detail/") {
return true
@@ -311,11 +817,12 @@ final class ShareViewController: UIViewController {
private static func firstURL(in raw: String) -> URL? {
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 {
return nil
}
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return detector.firstMatch(in: text, options: [], range: range)?.url
return detector.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text))?.url
}
}

View File

@@ -10,6 +10,7 @@ const NETFLIX_HOSTS = new Set([
const PRIME_HOSTS = new Set([
'www.primevideo.com',
'primevideo.com',
'app.primevideo.com', // iOS uygulama paylaşım linkleri
]);
export interface ParsedContentUrl {
@@ -31,11 +32,20 @@ export function parseSupportedContentUrl(rawUrl: string): ParsedContentUrl | nul
}
if (PRIME_HOSTS.has(hostname)) {
const detailIdMatch = parsedUrl.pathname.match(/\/detail\/([A-Za-z0-9]+)/);
if (!detailIdMatch) return null;
const id = detailIdMatch[1];
if (!id) return null;
return { provider: 'primevideo', id };
// Standart web URL: /detail/TITLE/ID veya /-/tr/detail/ID
// GTI formatı nokta ve tire içerebilir: amzn1.dv.gti.UUID
const detailIdMatch = parsedUrl.pathname.match(/\/detail\/([A-Za-z0-9._-]+)/);
if (detailIdMatch?.[1]) {
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;