From d268bc569645aa6932d9118e5c85280f73968af8 Mon Sep 17 00:00:00 2001 From: wisecolt Date: Tue, 3 Mar 2026 23:46:01 +0300 Subject: [PATCH] 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 --- .../ShareExtension/ShareViewController.swift | 779 +++++++++++++++--- src/utils/contentUrl.ts | 20 +- 2 files changed, 658 insertions(+), 141 deletions(-) diff --git a/ios/Ratebubble/ShareExtension/ShareViewController.swift b/ios/Ratebubble/ShareExtension/ShareViewController.swift index cf8047e..60477d3 100644 --- a/ios/Ratebubble/ShareExtension/ShareViewController.swift +++ b/ios/Ratebubble/ShareExtension/ShareViewController.swift @@ -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..