- 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
829 lines
36 KiB
Swift
829 lines
36 KiB
Swift
import UIKit
|
||
import UniformTypeIdentifiers
|
||
|
||
final class ShareViewController: UIViewController, UITextViewDelegate {
|
||
|
||
private enum ViewState {
|
||
case loading
|
||
case success(GetInfoResponse)
|
||
case error(String)
|
||
}
|
||
|
||
private struct CommentItem {
|
||
let user: String
|
||
let body: String
|
||
let time: String
|
||
}
|
||
|
||
private var selectedRating = 0.0
|
||
private var starButtons: [UIButton] = []
|
||
private var comments: [CommentItem] = []
|
||
|
||
private let headerView = UIView()
|
||
private let headerLabel = UILabel()
|
||
private let closeButton = UIButton(type: .system)
|
||
|
||
private let overlayView = UIView()
|
||
private let spinner = UIActivityIndicatorView(style: .large)
|
||
private let overlayLabel = UILabel()
|
||
|
||
private let scrollView = UIScrollView()
|
||
private 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 genreScroll = UIScrollView()
|
||
private let genreStack = UIStackView()
|
||
private let plotLabel = UILabel()
|
||
private let castLabel = UILabel()
|
||
|
||
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()
|
||
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()
|
||
setupScrollView()
|
||
setupOverlay()
|
||
setupFeedback()
|
||
|
||
Task { await handleIncomingShare() }
|
||
}
|
||
|
||
override func viewDidLayoutSubviews() {
|
||
super.viewDidLayoutSubviews()
|
||
gradientLayer.frame = backdropContainer.bounds
|
||
}
|
||
|
||
private func setupFeedback() {
|
||
hoverHaptic.prepare()
|
||
}
|
||
|
||
private func setupHeader() {
|
||
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.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 = UIColor.white.withAlphaComponent(0.08)
|
||
separator.translatesAutoresizingMaskIntoConstraints = false
|
||
headerView.addSubview(separator)
|
||
|
||
NSLayoutConstraint.activate([
|
||
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
headerView.heightAnchor.constraint(equalToConstant: 46),
|
||
|
||
headerLabel.centerXAnchor.constraint(equalTo: headerView.centerXAnchor),
|
||
headerLabel.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
|
||
|
||
closeButton.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -16),
|
||
closeButton.centerYAnchor.constraint(equalTo: headerView.centerYAnchor),
|
||
|
||
separator.leadingAnchor.constraint(equalTo: headerView.leadingAnchor),
|
||
separator.trailingAnchor.constraint(equalTo: headerView.trailingAnchor),
|
||
separator.bottomAnchor.constraint(equalTo: headerView.bottomAnchor),
|
||
separator.heightAnchor.constraint(equalToConstant: 0.5)
|
||
])
|
||
}
|
||
|
||
private func setupScrollView() {
|
||
scrollView.alwaysBounceVertical = true
|
||
scrollView.keyboardDismissMode = .interactive
|
||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||
scrollView.isHidden = true
|
||
view.addSubview(scrollView)
|
||
|
||
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)
|
||
])
|
||
|
||
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)
|
||
])
|
||
}
|
||
|
||
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:
|
||
overlayView.isHidden = false
|
||
scrollView.isHidden = true
|
||
spinner.startAnimating()
|
||
overlayLabel.text = "İçerik analiz ediliyor..."
|
||
|
||
case .success(let info):
|
||
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)
|
||
}
|
||
|
||
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)") }
|
||
heroMetaLabel.text = parts.joined(separator: " • ")
|
||
|
||
genreStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||
if info.genres.isEmpty {
|
||
genreScroll.isHidden = true
|
||
} else {
|
||
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):
|
||
overlayView.isHidden = false
|
||
scrollView.isHidden = true
|
||
spinner.stopAnimating()
|
||
overlayLabel.text = message
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
for provider in providers {
|
||
if let extracted = await extractURL(from: provider), isSupportedStreamingURL(extracted) {
|
||
let normalized = normalizeURL(extracted)
|
||
SharedPayloadStore.saveIncomingURL(normalized.absoluteString)
|
||
do {
|
||
let info = try await APIClient.shared.getInfo(url: normalized.absoluteString)
|
||
apply(.success(info))
|
||
} catch {
|
||
apply(.error("Bilgiler alınamadı.\n\(error.localizedDescription)"))
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
apply(.error("Geçerli bir Netflix/Prime Video linki bulunamadı."))
|
||
}
|
||
|
||
@objc private func closeTapped() {
|
||
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||
}
|
||
|
||
@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) {
|
||
return await withCheckedContinuation { continuation in
|
||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
|
||
if let url = item as? URL {
|
||
continuation.resume(returning: url)
|
||
return
|
||
}
|
||
if let raw = item as? String {
|
||
continuation.resume(returning: Self.firstURL(in: raw))
|
||
return
|
||
}
|
||
continuation.resume(returning: nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) {
|
||
return await withCheckedContinuation { continuation in
|
||
provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
|
||
if let raw = item as? String, let url = Self.firstURL(in: raw) {
|
||
continuation.resume(returning: url)
|
||
return
|
||
}
|
||
continuation.resume(returning: 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 {
|
||
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", "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
|
||
}
|
||
return !path.isEmpty && path != "/"
|
||
}
|
||
|
||
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
|
||
}
|
||
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
||
return nil
|
||
}
|
||
return detector.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text))?.url
|
||
}
|
||
}
|