Compare commits

..

9 Commits

Author SHA1 Message Date
d50eaf250d feat(ios): align app UX with share extension interactions
- redesign main app screen to dark card-based layout

- add half-star drag/tap rating with haptic feedback

- add in-app comments list and composer interactions
2026-03-05 12:31:27 +03:00
d268bc5696 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
2026-03-03 23:46:01 +03:00
8bd4f24774 feat(UI): ios için güncellemeler içerir. 2026-03-03 22:50:14 +03:00
5c6a829a4d feat(ios): share extension ile Ratebubble iOS istemcisini ekle ve paylaşım akışını düzelt 2026-03-01 18:07:07 +03:00
8c66fa9b82 feat(tmdb): cast alanina gore katı eslesmeli arama destegi ekle 2026-03-01 01:57:57 +03:00
79f90cb287 feat(ui): ana katalog basligini Ratebubble olarak guncelle 2026-03-01 01:57:13 +03:00
ad65453fcf feat(ui): saglayici logosu, kart duzeni ve admin silme onay modali ekle 2026-03-01 01:13:59 +03:00
84131576cf feat(api): Prime Video scraping ve saglayiciya duyarlı metadata destegi ekle 2026-03-01 01:13:41 +03:00
96d8a66a97 chore(gelistirme): frontend servisini docker compose yapisina ekle 2026-03-01 01:12:49 +03:00
27 changed files with 2470 additions and 22 deletions

View File

@@ -0,0 +1,29 @@
---
date: 2026-03-01
topic: ios-share-extension-v1
---
# iOS Share Extension v1
## What We're Building
Projeye ikinci bir frontend olarak native iOS uygulaması eklenecek. Uygulamanın v1 ana işlevi, Netflix uygulamasından paylaşılan içerik linkini almak ve backend API'ye göndererek metadata sonucunu kullanıcıya göstermek.
Akış: Netflix içerik sayfası -> Paylaş -> bizim iOS app (Share Extension) -> URL alma -> backend `/api/getinfo` isteği -> sonuçları metin olarak gösterme (`title`, `year`, vb.).
## Why This Approach
Share Extension seçimi, iOS paylaşım menüsüne doğal şekilde entegre olur ve kullanıcı davranışıyla birebir örtüşür. Deep link tabanlı alternatiflere göre daha güvenilir URL yakalama sağlar ve v1 için en düşük sürtünmeyle çalışır.
V1 kapsamını sadece “link al, API çağır, sonucu göster” ile sınırlamak, YAGNI prensibine uygundur ve ürünü hızlıca canlı doğrulamaya taşır.
## Key Decisions
- Entegrasyon tipi: Share Extension (zorunlu).
- Kapsam: Sadece Netflix paylaşım linki ile `/api/getinfo` çağrısı.
- Yetkilendirme: `X-API-Key` olarak `API_KEY_MOBILE` kullanılacak.
- Görsellik: UI/UX tasarımı v1 sonrası iterasyona bırakılacak.
## Open Questions
- Share Extension URLyi doğrudan APIye mi gönderecek, yoksa ana appe handoff edip ana app mi çağrı yapacak?
- Başarısız API yanıtlarında kullanıcıya minimum hangi hata metni gösterilecek?
## Next Steps
-> `/workflows:plan`

View File

@@ -0,0 +1,28 @@
---
date: 2026-03-01
topic: tmdb-cast-strict-matching
---
# TMDB Cast Bazlı Katı Eşleşme
## What We're Building
TMDB arama akışına opsiyonel `cast` alanı eklenecek. İstekte `cast` verildiğinde sistem, mevcut `title/year/seasonYear/seasonNumber/type` ile adayları bulduktan sonra ilk 5 adayı cast bilgisi ile doğrulayacak.
Cast doğrulaması katı olacak: verilen cast adı adayın oyuncu listesinde yoksa aday elenecek. İlk 5 adayda hiç eşleşme bulunmazsa boş sonuç dönülecek. `cast` verilmediğinde mevcut davranış korunacak.
## Why This Approach
Kullanıcı beklentisi yanlış eşleşmeleri azaltmak ve “başlık + tek oyuncu adı” ile daha doğru içeriği seçmek. Katı filtreleme, özellikle benzer isimli yapımlarda hatalı ilk sonucu engeller.
Top 5 doğrulama, doğruluk ve API maliyetini dengeler. `cast` alanını opsiyonel tutmak, mevcut istemcilerle geriye dönük uyumluluğu korur.
## Key Decisions
- `cast` alanı opsiyonel: Eski entegrasyonlar bozulmaz.
- Cast eşleşmesi katı: Eşleşme yoksa sonuç dönmez.
- Doğrulama kapsamı Top 5: Aşırı API çağrısından kaçınılır.
- Eşleşme modu esnek normalize: büyük/küçük harf, Türkçe karakter varyasyonları ve boşluk farklılıkları tolere edilir.
## Open Questions
- Cast eşleşmesi yokken yanıt sadece `results: []` mı olmalı, yoksa `reason` gibi açıklayıcı bir alan eklenmeli mi?
## Next Steps
-> `/workflows:plan`

View File

@@ -558,7 +558,7 @@ export function MoviesPage() {
const pageTitle = useMemo(() => { const pageTitle = useMemo(() => {
if (typeFilter === 'movie') return 'Film Arşivi' if (typeFilter === 'movie') return 'Film Arşivi'
if (typeFilter === 'tvshow') return 'Dizi Arşivi' if (typeFilter === 'tvshow') return 'Dizi Arşivi'
return 'Film ve Dizi Arşivi' return 'Ratebubble'
}, [typeFilter]) }, [typeFilter])
const allGenres = useMemo(() => { const allGenres = useMemo(() => {

39
ios/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Ratebubble iOS (v1)
Bu klasör, Ratebubble için iOS ana uygulama + Share Extension iskeletini içerir.
## Hedef
- Netflix uygulamasından paylaşılan URL'yi almak
- Ana app'e handoff etmek
- Backend `/api/getinfo` endpointine gönderip sonucu göstermek
## Gereksinimler
- Xcode 15+
- iOS 16+
- (Opsiyonel) XcodeGen
## Proje Oluşturma (XcodeGen)
```bash
cd ios
xcodegen generate
open Ratebubble.xcodeproj
```
Eğer `xcodegen` kurulu değilse:
```bash
brew install xcodegen
```
## Yapılandırma
`Ratebubble/Resources/Config.xcconfig` dosyasında:
- `API_BASE_URL`
- `MOBILE_API_KEY`
- `APP_GROUP_ID`
- `APP_URL_SCHEME`
değerlerini ortamına göre güncelle.
## Not
Share Extension, URL'yi App Group `UserDefaults` içine yazar ve custom URL scheme ile ana app'i açar.

View File

@@ -0,0 +1,454 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 63;
objects = {
/* Begin PBXBuildFile section */
0315885AA91662FE48BBC594 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E2C9211EC8E30B83ABBCFB /* ContentView.swift */; };
0C602ACFD0DC100ECCFA84A5 /* SharedPayloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */; };
17157E8CF084270023A56C06 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA40D68C79904DEE4125D874 /* Models.swift */; };
186EA66540EFA4CEA949617D /* RatebubbleShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7539736C403588176FB7D80C /* RatebubbleShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3993F1B14739F8177B844EE0 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */; };
3E1D36E3AE2CDBD9C402E921 /* RatebubbleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC8FF0D6783D4BFC590D370 /* RatebubbleApp.swift */; };
77D601458525635F705EA471 /* SharedPayloadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */; };
886AF155C91E366210524E45 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37749607968C1A751317B7BD /* MainViewModel.swift */; };
A547A283AD74B06B25FDB424 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72C1C571628107632B4A2F90 /* ShareViewController.swift */; };
B31B1B7EC5BAE4FB9011C94C /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */; };
F670AE07545EBB4ABE2889F7 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA40D68C79904DEE4125D874 /* Models.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
2FFA444A9840D8FEE9B59CB3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 7944D79A93FF32A326D78AB8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D66EABC12ABB12431BD3554C;
remoteInfo = RatebubbleShare;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
87D7EB893873C1953A4B8483 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
186EA66540EFA4CEA949617D /* RatebubbleShare.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
19F8E500E3A70D57454E49A6 /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = "<group>"; };
37749607968C1A751317B7BD /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = "<group>"; };
72C1C571628107632B4A2F90 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
7539736C403588176FB7D80C /* RatebubbleShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RatebubbleShare.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7FEF3C0785A60EA1B560627C /* Ratebubble.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ratebubble.app; sourceTree = BUILT_PRODUCTS_DIR; };
96E2C9211EC8E30B83ABBCFB /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
AA40D68C79904DEE4125D874 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedPayloadStore.swift; sourceTree = "<group>"; };
D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
FEC8FF0D6783D4BFC590D370 /* RatebubbleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RatebubbleApp.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
0EF0C634DF5D5383BE2D7771 /* Shared */ = {
isa = PBXGroup;
children = (
D3DD3EAEF55CD1A6EF04D923 /* APIClient.swift */,
AA40D68C79904DEE4125D874 /* Models.swift */,
CACEBEAEDF343884A3712AB1 /* SharedPayloadStore.swift */,
);
name = Shared;
path = Ratebubble/Shared;
sourceTree = "<group>";
};
108289137F406D4F63BFC3AE /* ShareExtension */ = {
isa = PBXGroup;
children = (
72C1C571628107632B4A2F90 /* ShareViewController.swift */,
);
name = ShareExtension;
path = Ratebubble/ShareExtension;
sourceTree = "<group>";
};
1C0E0078471B0855017A64DD = {
isa = PBXGroup;
children = (
EF496B275DF9294D162CFA6E /* App */,
BFD8EEAEAAE9601A16238D2D /* Resources */,
0EF0C634DF5D5383BE2D7771 /* Shared */,
108289137F406D4F63BFC3AE /* ShareExtension */,
E032C44ACA0299B26854540A /* Products */,
);
sourceTree = "<group>";
};
BFD8EEAEAAE9601A16238D2D /* Resources */ = {
isa = PBXGroup;
children = (
19F8E500E3A70D57454E49A6 /* Config.xcconfig */,
);
name = Resources;
path = Ratebubble/Resources;
sourceTree = "<group>";
};
E032C44ACA0299B26854540A /* Products */ = {
isa = PBXGroup;
children = (
7FEF3C0785A60EA1B560627C /* Ratebubble.app */,
7539736C403588176FB7D80C /* RatebubbleShare.appex */,
);
name = Products;
sourceTree = "<group>";
};
EF496B275DF9294D162CFA6E /* App */ = {
isa = PBXGroup;
children = (
96E2C9211EC8E30B83ABBCFB /* ContentView.swift */,
37749607968C1A751317B7BD /* MainViewModel.swift */,
FEC8FF0D6783D4BFC590D370 /* RatebubbleApp.swift */,
);
name = App;
path = Ratebubble/App;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1E5B7142527B8E64D5BB475A /* Ratebubble */ = {
isa = PBXNativeTarget;
buildConfigurationList = DE6F9D82ED742462C54ED81A /* Build configuration list for PBXNativeTarget "Ratebubble" */;
buildPhases = (
21317B3B7EEF00F8D3448F1A /* Sources */,
87D7EB893873C1953A4B8483 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
B360B60BC3BB55F1DEFD5F02 /* PBXTargetDependency */,
);
name = Ratebubble;
packageProductDependencies = (
);
productName = Ratebubble;
productReference = 7FEF3C0785A60EA1B560627C /* Ratebubble.app */;
productType = "com.apple.product-type.application";
};
D66EABC12ABB12431BD3554C /* RatebubbleShare */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3BCC0C2B27341BF3ADDCB3B9 /* Build configuration list for PBXNativeTarget "RatebubbleShare" */;
buildPhases = (
B64AC19652E4EA372111002C /* Sources */,
);
buildRules = (
);
dependencies = (
);
name = RatebubbleShare;
packageProductDependencies = (
);
productName = RatebubbleShare;
productReference = 7539736C403588176FB7D80C /* RatebubbleShare.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
7944D79A93FF32A326D78AB8 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
};
buildConfigurationList = BA6BB0478ED529686B10D5F3 /* Build configuration list for PBXProject "Ratebubble" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 1C0E0078471B0855017A64DD;
minimizedProjectReferenceProxies = 1;
projectDirPath = "";
projectRoot = "";
targets = (
1E5B7142527B8E64D5BB475A /* Ratebubble */,
D66EABC12ABB12431BD3554C /* RatebubbleShare */,
);
};
/* End PBXProject section */
/* Begin PBXSourcesBuildPhase section */
21317B3B7EEF00F8D3448F1A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B31B1B7EC5BAE4FB9011C94C /* APIClient.swift in Sources */,
0315885AA91662FE48BBC594 /* ContentView.swift in Sources */,
886AF155C91E366210524E45 /* MainViewModel.swift in Sources */,
17157E8CF084270023A56C06 /* Models.swift in Sources */,
3E1D36E3AE2CDBD9C402E921 /* RatebubbleApp.swift in Sources */,
77D601458525635F705EA471 /* SharedPayloadStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B64AC19652E4EA372111002C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
3993F1B14739F8177B844EE0 /* APIClient.swift in Sources */,
F670AE07545EBB4ABE2889F7 /* Models.swift in Sources */,
A547A283AD74B06B25FDB424 /* ShareViewController.swift in Sources */,
0C602ACFD0DC100ECCFA84A5 /* SharedPayloadStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
B360B60BC3BB55F1DEFD5F02 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D66EABC12ABB12431BD3554C /* RatebubbleShare */;
targetProxy = 2FFA444A9840D8FEE9B59CB3 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
03B172804432CFDEF0B15A28 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/RatebubbleShare.entitlements;
DEVELOPMENT_TEAM = S34SFUY9SC;
INFOPLIST_FILE = "Ratebubble/Resources/RatebubbleShare-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble.share;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
2C4CEF1731FBB13FC87F825C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.9;
};
name = Release;
};
3AE70298876BFAD681CF451B /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/Ratebubble.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = S34SFUY9SC;
INFOPLIST_FILE = "Ratebubble/Resources/Ratebubble-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
3B575860868E0B1AF5B7D410 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.9;
};
name = Debug;
};
5C1878476CD3B8F15D53C417 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/Ratebubble.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = S34SFUY9SC;
INFOPLIST_FILE = "Ratebubble/Resources/Ratebubble-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
A005F6D39148A83B59423F17 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 19F8E500E3A70D57454E49A6 /* Config.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Ratebubble/Resources/RatebubbleShare.entitlements;
DEVELOPMENT_TEAM = S34SFUY9SC;
INFOPLIST_FILE = "Ratebubble/Resources/RatebubbleShare-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = net.wisecolt.ratebubble.share;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3BCC0C2B27341BF3ADDCB3B9 /* Build configuration list for PBXNativeTarget "RatebubbleShare" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A005F6D39148A83B59423F17 /* Debug */,
03B172804432CFDEF0B15A28 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
BA6BB0478ED529686B10D5F3 /* Build configuration list for PBXProject "Ratebubble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3B575860868E0B1AF5B7D410 /* Debug */,
2C4CEF1731FBB13FC87F825C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
DE6F9D82ED742462C54ED81A /* Build configuration list for PBXNativeTarget "Ratebubble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3AE70298876BFAD681CF451B /* Debug */,
5C1878476CD3B8F15D53C417 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 7944D79A93FF32A326D78AB8 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D66EABC12ABB12431BD3554C"
BuildableName = "RatebubbleShare.appex"
BlueprintName = "RatebubbleShare"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1E5B7142527B8E64D5BB475A"
BuildableName = "Ratebubble.app"
BlueprintName = "Ratebubble"
ReferencedContainer = "container:Ratebubble.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,418 @@
import SwiftUI
import UIKit
struct ContentView: View {
@StateObject private var viewModel = MainViewModel()
@Environment(\.scenePhase) private var scenePhase
@State private var selectedRating: Double = 0
@State private var commentDraft = ""
@State private var comments: [CommentItem] = []
@State private var interactionKey = ""
private let hoverHaptic = UIImpactFeedbackGenerator(style: .light)
private let submitHaptic = UIImpactFeedbackGenerator(style: .medium)
var body: some View {
NavigationStack {
ZStack {
LinearGradient(
colors: [
Color(red: 0.05, green: 0.05, blue: 0.07),
Color(red: 0.02, green: 0.02, blue: 0.03)
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
urlComposerCard
if viewModel.isLoading {
loadingCard
}
if let error = viewModel.errorMessage {
errorCard(error)
}
if let result = viewModel.result {
resultCard(result)
ratingCard
commentsCard
} else if !viewModel.isLoading {
emptyCard
}
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
}
}
.toolbar {
ToolbarItem(placement: .principal) {
Text("Ratebubble")
.font(.headline)
.foregroundStyle(.white)
}
}
}
.preferredColorScheme(.dark)
.onAppear {
hoverHaptic.prepare()
submitHaptic.prepare()
viewModel.consumeSharedURLIfAny()
}
.onOpenURL { _ in viewModel.consumeSharedURLIfAny() }
.onChange(of: scenePhase) { phase in
if phase == .active { viewModel.consumeSharedURLIfAny() }
}
}
private var urlComposerCard: some View {
card {
VStack(alignment: .leading, spacing: 12) {
Text("Paylaşılan Link")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
HStack(spacing: 10) {
Image(systemName: "link")
.foregroundStyle(.white.opacity(0.65))
TextField("https://www.netflix.com/title/...", text: $viewModel.sharedURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.URL)
.foregroundStyle(.white)
}
.padding(.horizontal, 12)
.padding(.vertical, 11)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
Button {
Task { await viewModel.fetch() }
} label: {
HStack {
Spacer()
Text(viewModel.isLoading ? "Analiz Ediliyor..." : "İçeriği Analiz Et")
.font(.subheadline.weight(.semibold))
Spacer()
}
.padding(.vertical, 11)
.background(
LinearGradient(
colors: [Color.red.opacity(0.95), Color.orange.opacity(0.9)],
startPoint: .leading,
endPoint: .trailing
),
in: RoundedRectangle(cornerRadius: 12)
)
}
.disabled(viewModel.isLoading)
.foregroundStyle(.white)
.opacity(viewModel.isLoading ? 0.7 : 1)
}
}
}
private var loadingCard: some View {
card {
HStack(spacing: 12) {
ProgressView()
Text("İçerik çözülüyor ve metadata hazırlanıyor...")
.font(.footnote)
.foregroundStyle(.white.opacity(0.8))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func errorCard(_ message: String) -> some View {
card {
VStack(alignment: .leading, spacing: 8) {
Text("Bir sorun oluştu")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.red.opacity(0.95))
Text(message)
.font(.footnote)
.foregroundStyle(.white.opacity(0.82))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func resultCard(_ result: GetInfoResponse) -> some View {
let key = contentKey(result)
return card {
VStack(alignment: .leading, spacing: 10) {
providerBadge(result.provider)
Text(result.title)
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.lineLimit(3)
Text(metaLine(result))
.font(.footnote)
.foregroundStyle(.white.opacity(0.65))
if !result.genres.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(result.genres, id: \.self) { genre in
Text(genre)
.font(.caption.weight(.semibold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.09), in: Capsule())
}
}
}
}
if let plot = result.plot, !plot.isEmpty {
Text(plot)
.font(.footnote)
.foregroundStyle(.white.opacity(0.84))
.lineLimit(6)
}
}
.onAppear { prepareInteractionState(for: result, key: key) }
.onChange(of: key) { _ in prepareInteractionState(for: result, key: key) }
}
}
private var ratingCard: some View {
card {
VStack(alignment: .leading, spacing: 10) {
Text("Puanla")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
Text("Yarım yıldız da seçebilirsin. Sürükleyerek hızlı puan ver.")
.font(.caption)
.foregroundStyle(.white.opacity(0.6))
StarRatingBar(
rating: $selectedRating,
onChanged: { changed in
guard changed else { return }
hoverHaptic.impactOccurred(intensity: 0.8)
hoverHaptic.prepare()
}
)
.frame(height: 46)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var commentsCard: some View {
card {
VStack(alignment: .leading, spacing: 12) {
Text("Yorumlar")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
if comments.isEmpty {
Text("Henüz yorum yok. İlk yorumu sen yaz.")
.font(.footnote)
.foregroundStyle(.white.opacity(0.58))
} else {
ForEach(comments) { item in
CommentBubble(item: item)
}
}
VStack(alignment: .trailing, spacing: 8) {
ZStack(alignment: .topLeading) {
TextEditor(text: $commentDraft)
.scrollContentBackground(.hidden)
.foregroundStyle(.white)
.frame(minHeight: 88, maxHeight: 120)
.padding(8)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
if commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Yorumunu yaz...")
.font(.footnote)
.foregroundStyle(.white.opacity(0.38))
.padding(.horizontal, 18)
.padding(.vertical, 18)
.allowsHitTesting(false)
}
}
Button {
submitComment()
} label: {
Text("Gönder")
.font(.footnote.weight(.semibold))
.padding(.horizontal, 16)
.padding(.vertical, 9)
.background(Color.red.opacity(0.9), in: Capsule())
}
.disabled(commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.opacity(commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.45 : 1)
.foregroundStyle(.white)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var emptyCard: some View {
card {
VStack(alignment: .leading, spacing: 8) {
Text("Hazır")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
Text("Paylaşımdan gelen bir içerik varsa otomatik doldurulur. İstersen yukarıdan URL girip analiz başlat.")
.font(.footnote)
.foregroundStyle(.white.opacity(0.66))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func providerBadge(_ provider: String) -> some View {
Text(provider.uppercased())
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(
provider == "netflix" ? Color.red.opacity(0.92) :
(provider == "primevideo" ? Color.cyan.opacity(0.82) : Color.white.opacity(0.2)),
in: Capsule()
)
}
private func metaLine(_ result: GetInfoResponse) -> String {
var parts: [String] = []
if let year = result.year { parts.append(String(year)) }
parts.append(result.type == "movie" ? "Film" : "Dizi")
if let currentSeason = result.currentSeason { parts.append("Sezon \(currentSeason)") }
return parts.joined(separator: "")
}
private func contentKey(_ result: GetInfoResponse) -> String {
"\(result.provider)|\(result.title)|\(result.year.map(String.init) ?? "-")"
}
private func prepareInteractionState(for result: GetInfoResponse, key: String) {
guard interactionKey != key else { return }
interactionKey = key
selectedRating = 0
commentDraft = ""
comments = [
CommentItem(user: "deniz", body: "Sinematografi çok temiz, finali de iyi bağlamışlar.", time: "2 saat önce"),
CommentItem(user: "melis", body: "\(result.title) için tempo yer yer düşse de genel deneyim çok keyifli.", time: "Dün")
]
}
private func submitComment() {
let trimmed = commentDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
comments.insert(CommentItem(user: "sen", body: trimmed, time: "Şimdi"), at: 0)
commentDraft = ""
submitHaptic.impactOccurred(intensity: 0.5)
submitHaptic.prepare()
}
private func card<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
content()
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.white.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
)
}
}
private struct CommentItem: Identifiable {
let id = UUID()
let user: String
let body: String
let time: String
}
private struct CommentBubble: View {
let item: CommentItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("@\(item.user)")
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
Text(item.body)
.font(.footnote)
.foregroundStyle(.white.opacity(0.82))
Text(item.time)
.font(.caption2)
.foregroundStyle(.white.opacity(0.5))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
}
}
private struct StarRatingBar: View {
@Binding var rating: Double
let onChanged: (Bool) -> Void
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { idx in
Image(systemName: iconName(for: idx))
.font(.system(size: 30, weight: .medium))
.foregroundStyle(iconColor(for: idx))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let changed = updateRating(from: value.location.x, width: geo.size.width)
onChanged(changed)
}
)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Puan")
.accessibilityValue("\(String(format: "%.1f", rating)) yıldız")
}
private func iconName(for index: Int) -> String {
let value = Double(index)
if rating >= value { return "star.fill" }
if rating >= value - 0.5 { return "star.leadinghalf.filled" }
return "star"
}
private func iconColor(for index: Int) -> Color {
let value = Double(index)
return rating >= value - 0.5
? Color(red: 0.96, green: 0.74, blue: 0.20)
: Color.white.opacity(0.20)
}
@discardableResult
private func updateRating(from x: CGFloat, width: CGFloat) -> Bool {
guard width > 0 else { return false }
let clampedX = min(max(x, 0), width - 0.001)
let raw = (clampedX / width) * 5.0
let stepped = max(0.5, min(5.0, (raw * 2).rounded(.up) / 2))
guard abs(stepped - rating) > 0.001 else { return false }
rating = stepped
return true
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
@MainActor
final class MainViewModel: ObservableObject {
@Published var sharedURL: String = ""
@Published var isLoading: Bool = false
@Published var result: GetInfoResponse?
@Published var errorMessage: String?
func consumeSharedURLIfAny() {
guard let incoming = SharedPayloadStore.consumeIncomingURL(), !incoming.isEmpty else {
return
}
sharedURL = incoming
Task { await fetch() }
}
func fetch() async {
guard !sharedURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
errorMessage = "Paylaşılan URL boş olamaz."
return
}
isLoading = true
errorMessage = nil
result = nil
do {
result = try await APIClient.shared.getInfo(url: sharedURL)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct RatebubbleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,5 @@
SLASH = /
API_BASE_URL = http:$(SLASH)$(SLASH)192.168.1.124:3000
MOBILE_API_KEY = mobile-app-key-change-me-in-production
APP_GROUP_ID = group.net.wisecolt.ratebubble
APP_URL_SCHEME = ratebubble

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(APP_URL_SCHEME)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>API_BASE_URL</key>
<string>$(API_BASE_URL)</string>
<key>MOBILE_API_KEY</key>
<string>$(MOBILE_API_KEY)</string>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>Ratebubble Share</string>
<key>API_BASE_URL</key>
<string>$(API_BASE_URL)</string>
<key>MOBILE_API_KEY</key>
<string>$(MOBILE_API_KEY)</string>
<key>APP_GROUP_ID</key>
<string>$(APP_GROUP_ID)</string>
<key>APP_URL_SCHEME</key>
<string>$(APP_URL_SCHEME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>TRUEPREDICATE</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,838 @@
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.accessibilityLabel = "Ekranı kapat"
closeButton.accessibilityHint = "Paylaşım ekranını kapatır"
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"
button.accessibilityHint = "Puanı ayarlar"
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
submitCommentButton.accessibilityLabel = "Yorumu gönder"
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
}
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 renderComments() {
commentsListStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
guard !comments.isEmpty else {
let emptyState = UILabel()
emptyState.text = "Henüz yorum yok. İlk yorumu sen yaz."
emptyState.textColor = UIColor.white.withAlphaComponent(0.55)
emptyState.font = .systemFont(ofSize: 13, weight: .medium)
emptyState.numberOfLines = 0
commentsListStack.addArrangedSubview(emptyState)
return
}
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()
submitCommentButton.setTitle("Gönderildi", for: .normal)
submitCommentButton.backgroundColor = UIColor.systemGreen.withAlphaComponent(0.9)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
self.submitCommentButton.setTitle("Gönder", for: .normal)
self.submitCommentButton.backgroundColor = UIColor.systemRed.withAlphaComponent(0.92)
}
}
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
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
enum APIClientError: LocalizedError {
case invalidBaseURL
case invalidResponse
case server(String)
var errorDescription: String? {
switch self {
case .invalidBaseURL:
return "API_BASE_URL geçersiz."
case .invalidResponse:
return "Sunucudan geçerli yanıt alınamadı."
case .server(let message):
return message
}
}
}
final class APIClient {
static let shared = APIClient()
private init() {}
private var baseURL: URL? {
guard let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String else {
return nil
}
return URL(string: raw)
}
private var mobileAPIKey: String {
Bundle.main.object(forInfoDictionaryKey: "MOBILE_API_KEY") as? String
?? "mobile-dev-key-change-me"
}
func getInfo(url: String) async throws -> GetInfoResponse {
guard let baseURL else { throw APIClientError.invalidBaseURL }
var request = URLRequest(url: baseURL.appending(path: "/api/getinfo"))
request.httpMethod = "POST"
request.timeoutInterval = 20
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(mobileAPIKey, forHTTPHeaderField: "X-API-Key")
request.httpBody = try JSONEncoder().encode(GetInfoRequest(url: url))
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIClientError.invalidResponse
}
let decoder = JSONDecoder()
let envelope = try decoder.decode(APIEnvelope<GetInfoResponse>.self, from: data)
if (200..<300).contains(http.statusCode), envelope.success, let payload = envelope.data {
return payload
}
if let errorMessage = envelope.error?.message {
throw APIClientError.server(errorMessage)
}
throw APIClientError.server("İstek başarısız oldu (\(http.statusCode)).")
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
struct GetInfoRequest: Encodable {
let url: String
}
struct APIErrorPayload: Decodable, Error {
let code: String
let message: String
}
struct APIEnvelope<T: Decodable>: Decodable {
let success: Bool
let data: T?
let error: APIErrorPayload?
}
struct GetInfoResponse: Decodable {
let provider: String
let title: String
let year: Int?
let plot: String?
let ageRating: String?
let type: String
let genres: [String]
let cast: [String]
let backdrop: String?
let currentSeason: Int?
}

View File

@@ -0,0 +1,31 @@
import Foundation
enum SharedConfig {
static var appGroupID: String {
Bundle.main.object(forInfoDictionaryKey: "APP_GROUP_ID") as? String
?? "group.net.wisecolt.ratebubble"
}
static var appURLScheme: String {
Bundle.main.object(forInfoDictionaryKey: "APP_URL_SCHEME") as? String
?? "ratebubble"
}
}
enum SharedKeys {
static let incomingURL = "incoming_shared_url"
}
enum SharedPayloadStore {
static func saveIncomingURL(_ url: String) {
guard let defaults = UserDefaults(suiteName: SharedConfig.appGroupID) else { return }
defaults.set(url, forKey: SharedKeys.incomingURL)
defaults.synchronize()
}
static func consumeIncomingURL() -> String? {
guard let defaults = UserDefaults(suiteName: SharedConfig.appGroupID) else { return nil }
defer { defaults.removeObject(forKey: SharedKeys.incomingURL) }
return defaults.string(forKey: SharedKeys.incomingURL)
}
}

58
ios/project.yml Normal file
View File

@@ -0,0 +1,58 @@
name: Ratebubble
options:
deploymentTarget:
iOS: 16.0
configs:
Debug: debug
Release: release
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.wisecolt.ratebubble
SWIFT_VERSION: 5.9
targets:
Ratebubble:
type: application
platform: iOS
deploymentTarget: "16.0"
sources:
- path: Ratebubble/App
- path: Ratebubble/Shared
configFiles:
Debug: Ratebubble/Resources/Config.xcconfig
Release: Ratebubble/Resources/Config.xcconfig
info:
path: Ratebubble/Resources/Ratebubble-Info.plist
properties:
UILaunchScreen: {}
CFBundleURLTypes:
- CFBundleTypeRole: Editor
CFBundleURLSchemes:
- $(APP_URL_SCHEME)
entitlements:
path: Ratebubble/Resources/Ratebubble.entitlements
properties:
com.apple.security.application-groups:
- $(APP_GROUP_ID)
dependencies:
- target: RatebubbleShare
RatebubbleShare:
type: app-extension
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.wisecolt.ratebubble.share
platform: iOS
deploymentTarget: "16.0"
sources:
- path: Ratebubble/ShareExtension
- path: Ratebubble/Shared
configFiles:
Debug: Ratebubble/Resources/Config.xcconfig
Release: Ratebubble/Resources/Config.xcconfig
info:
path: Ratebubble/Resources/RatebubbleShare-Info.plist
entitlements:
path: Ratebubble/Resources/RatebubbleShare.entitlements
properties:
com.apple.security.application-groups:
- $(APP_GROUP_ID)

13
ios/scripts/bootstrap.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v xcodegen >/dev/null 2>&1; then
echo "xcodegen bulunamadı. Kurulum: brew install xcodegen"
exit 1
fi
xcodegen generate
echo "Tamamlandı: ios/Ratebubble.xcodeproj oluşturuldu."

View File

@@ -19,6 +19,7 @@ const tmdbSearchSchema = z.object({
type: z.enum(['movie', 'tv', 'multi']).optional(), type: z.enum(['movie', 'tv', 'multi']).optional(),
seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
seasonNumber: z.coerce.number().int().min(1).max(100).optional(), seasonNumber: z.coerce.number().int().min(1).max(100).optional(),
cast: z.string().trim().min(1).max(120).optional(),
}); });
/** /**
@@ -59,7 +60,7 @@ router.post(
return; return;
} }
const { query, year, type, seasonYear, seasonNumber } = result.data; const { query, year, type, seasonYear, seasonNumber, cast } = result.data;
try { try {
const searchResult = await TmdbService.search({ const searchResult = await TmdbService.search({
@@ -68,6 +69,7 @@ router.post(
type: type || 'multi', type: type || 'multi',
seasonYear, seasonYear,
seasonNumber, seasonNumber,
cast,
}); });
const response: ApiResponse<TmdbSearchResponse> = { const response: ApiResponse<TmdbSearchResponse> = {
@@ -106,6 +108,7 @@ router.post(
const movieSearchSchema = z.object({ const movieSearchSchema = z.object({
query: z.string().trim().min(1).max(200), query: z.string().trim().min(1).max(200),
year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
cast: z.string().trim().min(1).max(120).optional(),
}); });
const result = movieSearchSchema.safeParse(req.body); const result = movieSearchSchema.safeParse(req.body);
@@ -128,10 +131,10 @@ router.post(
return; return;
} }
const { query, year } = result.data; const { query, year, cast } = result.data;
try { try {
const searchResult = await TmdbService.searchMovies(query, year); const searchResult = await TmdbService.searchMovies(query, year, cast);
const response: ApiResponse<TmdbSearchResponse> = { const response: ApiResponse<TmdbSearchResponse> = {
success: true, success: true,
@@ -171,6 +174,7 @@ router.post(
year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), year: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(), seasonYear: z.coerce.number().int().min(1900).max(new Date().getFullYear() + 10).optional(),
seasonNumber: z.coerce.number().int().min(1).max(100).optional(), seasonNumber: z.coerce.number().int().min(1).max(100).optional(),
cast: z.string().trim().min(1).max(120).optional(),
}); });
const result = tvSearchSchema.safeParse(req.body); const result = tvSearchSchema.safeParse(req.body);
@@ -193,10 +197,10 @@ router.post(
return; return;
} }
const { query, year, seasonYear, seasonNumber } = result.data; const { query, year, seasonYear, seasonNumber, cast } = result.data;
try { try {
const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear); const searchResult = await TmdbService.searchTv(query, year, seasonNumber, seasonYear, cast);
const response: ApiResponse<TmdbSearchResponse> = { const response: ApiResponse<TmdbSearchResponse> = {
success: true, success: true,

View File

@@ -52,11 +52,85 @@ const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
* TMDB Image Base URL * TMDB Image Base URL
*/ */
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/original'; const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/original';
const CAST_FILTER_CANDIDATE_LIMIT = 5;
/** /**
* TMDB Service for movie/TV show search * TMDB Service for movie/TV show search
*/ */
export class TmdbService { export class TmdbService {
private static normalizeCastName(name: string): string {
return name
.normalize('NFKC')
.trim()
.replace(/[-‐‑‒–—―'`.]/g, ' ')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.toLocaleLowerCase('tr')
.replace(/[ıİ]/g, 'i')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
private static isCastNameMatch(candidate: string, requested: string): boolean {
const normalizedCandidate = this.normalizeCastName(candidate);
const normalizedRequested = this.normalizeCastName(requested);
if (!normalizedCandidate || !normalizedRequested) return false;
if (normalizedCandidate === normalizedRequested) return true;
// Secondary strictness: allow spacing variants like "eun jin" vs "eunjin"
const compactCandidate = normalizedCandidate.replace(/\s+/g, '');
const compactRequested = normalizedRequested.replace(/\s+/g, '');
return compactCandidate === compactRequested;
}
private static async getCreditsCastNames(
mediaType: 'movie' | 'tv',
tmdbId: number
): Promise<string[]> {
const url = `${TMDB_BASE_URL}/${mediaType}/${tmdbId}/credits`;
try {
const response = await fetch(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
return [];
}
const data = await response.json() as { cast?: Array<{ name?: string | null }> };
const castNames = Array.isArray(data.cast) ? data.cast : [];
return castNames
.map((item) => (typeof item?.name === 'string' ? item.name : ''))
.filter((name) => name.length > 0);
} catch {
return [];
}
}
private static async filterResultsByCast(
results: TmdbSearchResult[],
castName: string
): Promise<TmdbSearchResult[]> {
const normalizedRequested = this.normalizeCastName(castName);
if (!normalizedRequested) return [];
const candidates = results.slice(0, CAST_FILTER_CANDIDATE_LIMIT);
const matched: TmdbSearchResult[] = [];
for (const candidate of candidates) {
const castNames = await this.getCreditsCastNames(candidate.type, candidate.id);
const hasMatch = castNames.some((name) => this.isCastNameMatch(name, castName));
if (hasMatch) {
matched.push(candidate);
}
}
return matched;
}
/** /**
* Get common headers for TMDB API requests * Get common headers for TMDB API requests
*/ */
@@ -265,7 +339,11 @@ export class TmdbService {
/** /**
* Search for movies * Search for movies
*/ */
static async searchMovies(query: string, year?: number): Promise<TmdbSearchResponse> { static async searchMovies(
query: string,
year?: number,
cast?: string
): Promise<TmdbSearchResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
language: 'tr-TR', language: 'tr-TR',
@@ -292,15 +370,19 @@ export class TmdbService {
const data: TmdbRawResponse = await response.json(); const data: TmdbRawResponse = await response.json();
const results = data.results const normalizedResults = data.results
.map((r) => this.normalizeMovie(r as TmdbRawMovie)) .map((r) => this.normalizeMovie(r as TmdbRawMovie))
.filter((r): r is TmdbSearchResult => r !== null); .filter((r): r is TmdbSearchResult => r !== null);
const results = cast
? await this.filterResultsByCast(normalizedResults, cast)
: normalizedResults;
return { return {
page: data.page, page: data.page,
results, results,
totalPages: data.total_pages, totalPages: data.total_pages,
totalResults: data.total_results, totalResults: results.length,
}; };
} }
@@ -315,7 +397,8 @@ export class TmdbService {
query: string, query: string,
year?: number, year?: number,
seasonNumber?: number, seasonNumber?: number,
seasonYear?: number seasonYear?: number,
cast?: string
): Promise<TmdbSearchResponse> { ): Promise<TmdbSearchResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
@@ -354,6 +437,10 @@ export class TmdbService {
results = await this.filterAndEnrichTvResultsBySeason(results, seasonNumber, seasonYear); results = await this.filterAndEnrichTvResultsBySeason(results, seasonNumber, seasonYear);
} }
if (cast) {
results = await this.filterResultsByCast(results, cast);
}
return { return {
page: data.page, page: data.page,
results, results,
@@ -365,7 +452,11 @@ export class TmdbService {
/** /**
* Multi search (movies, TV shows, and people) * Multi search (movies, TV shows, and people)
*/ */
static async searchMulti(query: string, year?: number): Promise<TmdbSearchResponse> { static async searchMulti(
query: string,
year?: number,
cast?: string
): Promise<TmdbSearchResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
query, query,
language: 'tr-TR', language: 'tr-TR',
@@ -393,16 +484,20 @@ export class TmdbService {
const data: TmdbRawResponse = await response.json(); const data: TmdbRawResponse = await response.json();
// Filter out person results and normalize // Filter out person results and normalize
const results = data.results const normalizedResults = data.results
.filter((r) => r.media_type !== 'person') .filter((r) => r.media_type !== 'person')
.map((r) => this.normalizeResult(r)) .map((r) => this.normalizeResult(r))
.filter((r): r is TmdbSearchResult => r !== null); .filter((r): r is TmdbSearchResult => r !== null);
const results = cast
? await this.filterResultsByCast(normalizedResults, cast)
: normalizedResults;
return { return {
page: data.page, page: data.page,
results, results,
totalPages: data.total_pages, totalPages: data.total_pages,
totalResults: data.total_results, totalResults: results.length,
}; };
} }
@@ -411,17 +506,17 @@ export class TmdbService {
* @param request Search request with query, year, type, and optional season parameters * @param request Search request with query, year, type, and optional season parameters
*/ */
static async search(request: TmdbSearchRequest): Promise<TmdbSearchResponse> { static async search(request: TmdbSearchRequest): Promise<TmdbSearchResponse> {
const { query, year, type = 'multi', seasonYear, seasonNumber } = request; const { query, year, type = 'multi', seasonYear, seasonNumber, cast } = request;
switch (type) { switch (type) {
case 'movie': case 'movie':
return this.searchMovies(query, year); return this.searchMovies(query, year, cast);
case 'tv': case 'tv':
// For TV shows, use season parameters if provided // For TV shows, use season parameters if provided
return this.searchTv(query, year, seasonNumber, seasonYear); return this.searchTv(query, year, seasonNumber, seasonYear, cast);
case 'multi': case 'multi':
default: default:
return this.searchMulti(query, year); return this.searchMulti(query, year, cast);
} }
} }
} }

View File

@@ -219,6 +219,7 @@ export interface TmdbSearchRequest {
type?: 'movie' | 'tv' | 'multi'; type?: 'movie' | 'tv' | 'multi';
seasonYear?: number; seasonYear?: number;
seasonNumber?: number; seasonNumber?: number;
cast?: string;
} }
export interface TmdbSearchResult { export interface TmdbSearchResult {

View File

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