首次提交

This commit is contained in:
zeng 2026-03-03 13:53:32 +08:00
parent 44bf2c4512
commit 001f392af6
331 changed files with 13777 additions and 0 deletions

47
.gitignore vendored
View File

@ -13,6 +13,7 @@ xcuserdata/
*.ipa
*.dSYM.zip
*.dSYM
Podfile.lock
## Playgrounds
timeline.xctimeline
@ -32,6 +33,52 @@ playground.xcworkspace
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
*.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# ---> Objective-C
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However

43
Podfile Normal file
View File

@ -0,0 +1,43 @@
# Uncomment the next line to define a global platform for your project
platform :ios, '15.0'
target 'XSeri' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for XSeri
pod 'SnapKit'
pod 'Kingfisher'
pod 'Moya'
pod 'SmartCodable', '5.0.15'
pod 'ESTabBarController-swift'
pod 'JXSegmentedView'
pod 'collection-view-layouts/TagsLayout'
pod 'FSPagerView'
pod 'YYCategories'
pod 'YYText'
pod 'JXPlayer'
pod 'LYEmptyView'
pod 'FDFullscreenPopGesture'
pod 'SVProgressHUD'
pod 'Toast'
pod 'MJRefresh'
pod 'HWPanModal'
pod 'ZLPhotoBrowser'
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
# 统一设置部署版本
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
# 移除可能导致模拟器运行报错的排除架构设置,让 Xcode 自动处理
config.build_settings.delete('EXCLUDED_ARCHS[sdk=iphonesimulator*]')
config.build_settings.delete('EXCLUDED_ARCHITECTURES')
# 禁用 Bitcode (现代项目通常不需要)
config.build_settings['ENABLE_BITCODE'] = 'NO'
end
end
end
end

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
//
// AppDelegate+Config.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import MJRefresh
extension AppDelegate {
func config() {
UIView.xs_awake()
XSToast.config()
MJRefreshConfig.default.languageCode = "en"
let appearance = UINavigationBarAppearance.defaultAppearance()
UINavigationBar.appearance().scrollEdgeAppearance = appearance
UINavigationBar.appearance().standardAppearance = appearance
}
}

View File

@ -0,0 +1,53 @@
//
// AppDelegate.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
XSNetworkMonitorManager.manager.startMonitoring()
NotificationCenter.default.addObserver(self, selector: #selector(networkStatusDidChangeNotification), name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
self.config()
Task {
await XSLoginManager.manager.updateUserInfo()
}
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}
extension AppDelegate {
@objc private func networkStatusDidChangeNotification() {
guard XSNetworkMonitorManager.manager.isReachable == true else { return }
Task {
await XSLoginManager.manager.updateUserInfo()
}
}
}

View File

@ -0,0 +1,53 @@
//
// SceneDelegate.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
XSTool.windowScene = windowScene
self.window = UIWindow(windowScene: windowScene)
self.window?.rootViewController = XSTabBarController()
self.window?.makeKeyAndVisible()
}
func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
}
func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call).
}
func sceneWillEnterForeground(_ scene: UIScene) {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
}
func sceneDidEnterBackground(_ scene: UIScene) {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
}
}

View File

@ -0,0 +1,28 @@
import UIKit
///
struct XSConfig {
struct Color {
/// TabBar (#0F0F0F)
static let tabBarBg = UIColor(red: 0.059, green: 0.059, blue: 0.059, alpha: 1.0)
/// TabBar (#FFDAA4)
static let tabBarActive = UIColor(red: 1.0, green: 0.855, blue: 0.643, alpha: 1.0)
/// TabBar ( 40% )
static let tabBarInactive = UIColor.white.withAlphaComponent(0.4)
/// TabBar 线 ( 4% )
static let tabBarSeparator = UIColor.white.withAlphaComponent(0.04)
}
struct Font {
/// TabBar (Medium 12pt)
static let tabBarActive = UIFont.systemFont(ofSize: 12, weight: .medium)
/// TabBar (Regular 11pt)
static let tabBarInactive = UIFont.systemFont(ofSize: 11, weight: .regular)
}
}

View File

@ -0,0 +1,47 @@
//
// XSDefine.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
///
let kXSSystemVersion: String = UIDevice.current.systemVersion
let kXSBundleIdentifier: String = (Bundle.main.infoDictionary!["CFBundleIdentifier"] as? String) ?? "0"
///app
let kXSVersion: String = (Bundle.main.infoDictionary!["CFBundleShortVersionString"] as? String) ?? "0"
let kXSBundleVersion: String = (Bundle.main.infoDictionary!["CFBundleVersion"] as? String) ?? "0"
let kXSName: String = (Bundle.main.infoDictionary!["CFBundleDisplayName"] as? String) ?? ""
let kXSBundleName: String = (Bundle.main.infoDictionary!["CFBundleName"] as? String) ?? ""
#if DEBUG
func xsLog(_ msg: Any?, file: String = #file, function: String = #function, line: Int = #line) {
if let msg = msg {
print("\n\(Date(timeIntervalSinceNow: 8 * 60 * 60)) \(file.components(separatedBy: "/").last ?? "") \(function) \(line): \(msg)")
}
}
#else
func xsLog(_ msg: Any?) { }
#endif
public func xs_swizzled_instanceMethod(_ prefix: String, oldClass: Swift.AnyClass!, oldSelector: String, newClass: Swift.AnyClass) {
let newSelector = prefix + "_" + oldSelector;
let originalSelector = NSSelectorFromString(oldSelector)
let swizzledSelector = NSSelectorFromString(newSelector)
let originalMethod = class_getInstanceMethod(oldClass, originalSelector)
let swizzledMethod = class_getInstanceMethod(newClass, swizzledSelector)
let isAdd = class_addMethod(oldClass, originalSelector, method_getImplementation(swizzledMethod!), method_getTypeEncoding(swizzledMethod!))
if isAdd {
class_replaceMethod(newClass, swizzledSelector, method_getImplementation(originalMethod!), method_getTypeEncoding(originalMethod!))
}else {
method_exchangeImplementations(originalMethod!, swizzledMethod!)
}
}

View File

@ -0,0 +1,51 @@
//
// XSScreen.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
struct XSScreen {
static var screen: UIScreen {
return UIScreen.main
}
static var width: CGFloat {
return UIScreen.main.bounds.width
}
static var height: CGFloat {
return UIScreen.main.bounds.height
}
static var safeTop: CGFloat {
return XSTool.keyWindow?.safeAreaInsets.top ?? 20
}
static var safeBottom: CGFloat {
return XSTool.keyWindow?.safeAreaInsets.bottom ?? 0
}
static var navBarHeight: CGFloat {
return safeTop + 44
}
static var tabBarHeight: CGFloat {
return safeBottom + 49
}
static var customTabBarHeight: CGFloat {
return safeBottom + 60
}
///
static var widthRatio: CGFloat {
return width / 375
}
static func getRatioWidth(size: CGFloat) -> CGFloat {
return self.widthRatio * size
}
}

View File

@ -0,0 +1,11 @@
//
// XSUserDefaultsKey.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
///token
let kXSLoginTokenDefaultsKey = "kXSLoginTokenDefaultsKey"
///
let kXSUserInfoDefaultsKey = "kXSUserInfoDefaultsKey"

View File

@ -0,0 +1,32 @@
//
// XSCommonViewController.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
import SnapKit
class XSCommonViewController: XSViewController {
private(set) lazy var bgImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "bg_image_01"))
return imageView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
view.addSubview(bgImageView)
bgImageView.snp.makeConstraints { make in
make.left.right.top.equalToSuperview()
}
}
}

View File

@ -0,0 +1,47 @@
//
// XSNavigationController.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import FDFullscreenPopGesture
class XSNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
if #available(iOS 26.0, *) {
self.interactiveContentPopGestureRecognizer?.isEnabled = false
}
fd_fullscreenPopGestureRecognizer.isEnabled = true
}
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
if children.count > 0 {
viewController.hidesBottomBarWhenPushed = true
}
super.pushViewController(viewController, animated: animated)
}
override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
for (index, value) in viewControllers.enumerated() {
if index != 0 {
value.hidesBottomBarWhenPushed = true
}
}
super.setViewControllers(viewControllers, animated: animated)
}
override var childForStatusBarStyle: UIViewController? {
return self.topViewController
}
override var childForStatusBarHidden: UIViewController? {
return self.topViewController
}
}

View File

@ -0,0 +1,101 @@
//
// XSTabBarController.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import ESTabBarController_swift
class XSTabBarController: ESTabBarController {
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .black
setupCustomTabBar()
setupTabBarAppearance()
setupViewControllers()
}
/// 使 TabBar sizeThatFits
private func setupCustomTabBar() {
let tabBar = XSCustomTabBar()
tabBar.delegate = self
self.setValue(tabBar, forKey: "tabBar")
}
/// TabBar
private func setupTabBarAppearance() {
//
self.tabBar.backgroundColor = XSConfig.Color.tabBarBg
// 线 (使 XSConfig white 4%)
self.tabBar.shadowImage = UIImage()
self.tabBar.backgroundImage = UIImage()
// TabBar 20pt
self.tabBar.layer.cornerRadius = 20
self.tabBar.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
self.tabBar.clipsToBounds = true
// 线 ESTabBar shadow
let line = UIView()
line.backgroundColor = XSConfig.Color.tabBarSeparator
self.tabBar.addSubview(line)
line.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 0.5)
}
///
private func setupViewControllers() {
let homeVC = createNavController(
rootVC: XSHomeViewController(),
title: "Home".localized,
imageName: "tab_home_unsel",
selectedImageName: "tab_home_sel"
)
let discoverVC = createNavController(
rootVC: XSDiscoverViewController(),
title: "Discover".localized,
imageName: "tab_discover_unsel",
selectedImageName: "tab_discover_sel"
)
let myListVC = createNavController(
rootVC: XSMyListViewController(),
title: "My List".localized,
imageName: "tab_list_unsel",
selectedImageName: "tab_list_sel"
)
let portfolioVC = createNavController(
rootVC: XSMineViewController(),
title: "Portfolio".localized,
imageName: "tab_portfolio_unsel",
selectedImageName: "tab_portfolio_sel"
)
viewControllers = [homeVC, discoverVC, myListVC, portfolioVC]
}
/// ESTabBarItem
private func createNavController(rootVC: UIViewController, title: String, imageName: String, selectedImageName: String) -> UINavigationController {
let nav = XSNavigationController(rootViewController: rootVC)
let itemContents = XSTabBarItemContentView()
//
let item = ESTabBarItem(
itemContents,
title: title,
image: UIImage(named: imageName),
selectedImage: UIImage(named: selectedImageName)
)
nav.tabBarItem = item
return nav
}
}

View File

@ -0,0 +1,94 @@
//
// XSViewController.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import JXSegmentedView
class XSViewController: UIViewController {
var backImage: UIImage? {
return UIImage(named: "arrow_left_icon_03")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = ._0_F_0_F_0_F
if let navi = navigationController {
if navi.visibleViewController == self {
if navi.viewControllers.count > 1 {
configNavigationBack(image: backImage)
}
}
}
}
func handleHeaderRefresh(_ completer: (() -> Void)?) {
completer?()
}
func handleFooterRefresh(_ completer: (() -> Void)?) {
completer?()
}
}
extension UIViewController {
func configNavigationBack(image: UIImage?) {
if let image = image {
let leftBarButtonItem = UIBarButtonItem(image: image, style: .plain ,target: self,action: #selector(handleNavigationBack))
navigationItem.leftBarButtonItem = leftBarButtonItem
} else {
navigationItem.leftBarButtonItem = nil
}
}
@objc func handleNavigationBack() {
self.xs_toLastViewController(animated: true)
}
func xs_toLastViewController(animated: Bool) {
if self.navigationController != nil
{
if self.navigationController?.viewControllers.count == 1
{
self.dismiss(animated: animated, completion: nil)
} else {
self.navigationController?.popViewController(animated: animated)
}
}
else if self.presentingViewController != nil {
self.dismiss(animated: animated, completion: nil)
}
}
}
//MARK: JXSegmentedListContainerViewListDelegate
extension XSViewController: JXSegmentedListContainerViewListDelegate {
func listView() -> UIView {
return self.view
}
}
extension UIViewController {
func xs_setNavigationStyle(backgroundColor: UIColor = .clear,
titleFont: UIFont = .font(ofSize: 18, weight: .bold),
titleColor: UIColor = .white,
isTranslucent: Bool = true
) {
self.navigationController?.navigationBar.xs_setTranslucent(isTranslucent)
self.navigationController?.navigationBar.xs_setBackgroundColor(backgroundColor)
self.navigationController?.navigationBar.xs_setTitleTextAttributes([
NSAttributedString.Key.font : titleFont,
NSAttributedString.Key.foregroundColor : titleColor
])
}
}

View File

@ -0,0 +1,65 @@
//
// CGMutablePath+XS.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
struct XSRoundedCorner {
var topLeft:CGFloat = 0
var topRight:CGFloat = 0
var bottomLeft:CGFloat = 0
var bottomRight:CGFloat = 0
public static let zero = XSRoundedCorner(topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0)
public init(topLeft: CGFloat, topRight:CGFloat, bottomLeft:CGFloat, bottomRight:CGFloat) {
self.topLeft = topLeft
self.topRight = topRight
self.bottomLeft = bottomLeft
self.bottomRight = bottomRight
}
static func ==(v1:XSRoundedCorner, v2:XSRoundedCorner) -> Bool {
return v1.bottomLeft == v2.bottomLeft
&& v1.bottomRight == v2.bottomRight
&& v1.topLeft == v2.topLeft
&& v1.topRight == v2.topRight
}
static func !=(v1:XSRoundedCorner, v2:XSRoundedCorner) -> Bool {
return !(v1 == v2)
}
}
extension CGMutablePath {
func addRadiusRectangle(_ circulars: XSRoundedCorner, rect: CGRect) {
let minX = rect.minX
let minY = rect.minY
let maxX = rect.maxX
let maxY = rect.maxY
//
let topLeftCenterX = minX + circulars.topLeft
let topLeftCenterY = minY + circulars.topLeft
let topRightCenterX = maxX - circulars.topRight
let topRightCenterY = minY + circulars.topRight
let bottomLeftCenterX = minX + circulars.bottomLeft
let bottomLeftCenterY = maxY - circulars.bottomLeft
let bottomRightCenterX = maxX - circulars.bottomRight
let bottomRightCenterY = maxY - circulars.bottomRight
//
addArc(center: CGPoint(x: topLeftCenterX, y: topLeftCenterY), radius: circulars.topLeft, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 3 / 2, clockwise: false)
//
addArc(center: CGPoint(x: topRightCenterX, y: topRightCenterY), radius: circulars.topRight, startAngle: CGFloat.pi * 3 / 2, endAngle: 0, clockwise: false)
//
addArc(center: CGPoint(x: bottomRightCenterX, y: bottomRightCenterY), radius: circulars.bottomRight, startAngle: 0, endAngle: CGFloat.pi / 2, clockwise: false)
//
addArc(center: CGPoint(x: bottomLeftCenterX, y: bottomLeftCenterY), radius: circulars.bottomLeft, startAngle: CGFloat.pi / 2, endAngle: CGFloat.pi, clockwise: false)
closeSubpath();
}
}

View File

@ -0,0 +1,23 @@
//
// Dictionary+XS.swift
// XSeri
//
// Created by 鸿 on 2026/2/28.
//
import UIKit
extension Dictionary {
func toJsonString() -> String? {
do {
let data = try JSONSerialization.data(withJSONObject: self)
let jsonStr = String(data: data, encoding: .utf8)
return jsonStr
} catch {
}
return nil
}
}

View File

@ -0,0 +1,35 @@
//
// NSNumber+XS.swift
// XSeri
//
// Created by 鸿 on 2026/2/26.
//
import UIKit
extension NSNumber {
func toString(maximumFractionDigits: Int = 10, minimumFractionDigits: Int? = nil, roundingMode: NumberFormatter.RoundingMode? = nil) -> String {
let formatter = NumberFormatter()
formatter.minimumIntegerDigits = 1
formatter.maximumFractionDigits = maximumFractionDigits
if let minimumFractionDigits = minimumFractionDigits {
formatter.minimumFractionDigits = minimumFractionDigits
}
if let roundingMode = roundingMode {
formatter.roundingMode = roundingMode
}
formatter.numberStyle = .none
return formatter.string(from: self) ?? "0"
}
func format() -> String {
let num = self.floatValue
if num > 1000 {
return NSNumber(value: num / 1000).toString(maximumFractionDigits: 1) + "K"
} else {
return self.stringValue
}
}
}

View File

@ -0,0 +1,30 @@
//
// String+XS.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import SmartCodable
extension String: SmartCodable {
var localized: String {
var text = NSLocalizedString(self, comment: "")
text = text.replacingOccurrences(of: "<br>", with: "\n")
return text
}
func localizedReplace(text: String?) -> String {
return self.localized.replacingOccurrences(of: "##", with: text ?? "")
}
}
extension String {
var length:Int { return (self as NSString).length }
func size(_ font: UIFont, _ size: CGSize) -> CGSize {
return (self as NSString).size(for: font, size: size, mode: .byWordWrapping)
}
}

View File

@ -0,0 +1,16 @@
//
// UIFont+XS.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
extension UIFont {
static func font(ofSize fontSize: CGFloat, weight: Weight) -> UIFont {
return UIFont.systemFont(ofSize: fontSize, weight: weight)
}
}

View File

@ -0,0 +1,49 @@
//
// UINavigationBar+XS.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
extension UINavigationBarAppearance {
static func defaultAppearance() -> UINavigationBarAppearance {
let navBarAppearance = UINavigationBarAppearance()
navBarAppearance.configureWithOpaqueBackground()
navBarAppearance.backgroundColor = .clear
navBarAppearance.backgroundEffect = nil
navBarAppearance.shadowColor = UIColor.clear
navBarAppearance.titleTextAttributes = [
NSAttributedString.Key.font : UIFont.font(ofSize: 18, weight: .bold),
NSAttributedString.Key.foregroundColor : UIColor.white
]
return navBarAppearance
}
}
extension UINavigationBar {
func xs_setTranslucent(_ isTranslucent: Bool) {
self.isTranslucent = isTranslucent
}
func xs_setBackgroundColor(_ backgroundColor: UIColor?) {
let appearance = self.standardAppearance
appearance.backgroundColor = backgroundColor
self.standardAppearance = appearance
self.scrollEdgeAppearance = appearance
}
func xs_setTitleTextAttributes(_ titleTextAttributes: [NSAttributedString.Key : Any]?) {
let appearance = self.standardAppearance
if let titleTextAttributes = titleTextAttributes {
appearance.titleTextAttributes = titleTextAttributes
}
self.scrollEdgeAppearance = appearance
self.standardAppearance = appearance
}
}

View File

@ -0,0 +1,34 @@
//
// UIScrollView+Refresh.swift
// XSeri
//
// Created by 鸿 on 2026/2/25.
//
import MJRefresh
extension UIScrollView {
func xs_addRefreshHeader(insetTop: CGFloat = 0, block: (() -> Void)?) {
self.mj_header = MJRefreshNormalHeader(refreshingBlock: {
block?()
})
self.mj_header?.ignoredScrollViewContentInsetTop = insetTop
}
func xs_addRefreshFooter(insetBottom: CGFloat = 0, block: (() -> Void)?) {
self.mj_footer = MJRefreshBackNormalFooter(refreshingBlock: {
block?()
})
self.mj_footer?.ignoredScrollViewContentInsetBottom = insetBottom
}
func xs_endHeaderRefreshing() {
self.mj_header?.endRefreshing()
}
func xs_endFooterRefreshing() {
self.mj_footer?.endRefreshing()
}
}

View File

@ -0,0 +1,22 @@
//
// UIStackView.swift
// XSeri
//
// Created by 鸿 on 2026/2/25.
//
import UIKit
extension UIStackView {
func xs_removeAllArrangedSubview() {
let arrangedSubviews = self.arrangedSubviews
arrangedSubviews.forEach {
self.removeArrangedSubview($0)
$0.removeFromSuperview()
}
}
}

View File

@ -0,0 +1,65 @@
//
// UIView+XS.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
extension UIView {
fileprivate struct AssociatedKeys {
static var xs_roundedCorner: Int?
static var xs_effect: Int?
}
@objc public static func xs_awake() {
xs_swizzled_instanceMethod("xs", oldClass: self, oldSelector: "layoutSubviews", newClass: self)
}
@objc func xs_layoutSubviews() {
xs_layoutSubviews()
xs_updateRoundedCorner()
//
// if let effectView = effectView, effectView.frame != self.bounds {
// effectView.frame = self.bounds
// }
}
}
//MARK: -------------- --------------
extension UIView {
private var roundedCorner: XSRoundedCorner? {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.xs_roundedCorner) as? XSRoundedCorner
}
set {
objc_setAssociatedObject(self, &AssociatedKeys.xs_roundedCorner, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
///
func xs_setCornerRadius(topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) {
//
self.roundedCorner = XSRoundedCorner(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight)
xs_updateRoundedCorner()
}
func xs_updateRoundedCorner() {
guard let roundedCorner = self.roundedCorner else { return }
let rect = self.bounds
let path = CGMutablePath()
path.addRadiusRectangle(roundedCorner, rect: rect)
let maskLayer = CAShapeLayer()
maskLayer.frame = self.bounds
maskLayer.path = path
self.layer.mask = maskLayer
}
}

View File

@ -0,0 +1,44 @@
//
// UserDefaults+XS.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
extension UserDefaults {
static func xs_setObject(_ obj: NSSecureCoding?, forKey key: String) {
let defaults = UserDefaults.standard
guard let obj = obj else {
defaults.removeObject(forKey: key)
return
}
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: obj, requiringSecureCoding: true)
defaults.set(data, forKey: key)
} catch {
print("\(error)")
}
}
static func xs_object<T: NSObject & NSSecureCoding>(forKey key: String, as type: T.Type) -> T? {
let defaults = UserDefaults.standard
guard let data = defaults.data(forKey: key) else {
return nil
}
do {
let object = try NSKeyedUnarchiver.unarchivedObject(ofClass: type, from: data)
return object
} catch {
print("\(error)")
return nil
}
}
}

View File

@ -0,0 +1,105 @@
//
// XSHomeAPI.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
struct XSHomeAPI {
static func requestHomeData() async -> [XSHomeModuleItem]? {
var parameters = XSNetwork.Parameters(path: "/home/all-modules")
parameters.method = .get
parameters.isToast = true
let response: XSNetwork.Response<XSNetwork.List<XSHomeModuleItem>> = await XSNetwork.request(parameters: parameters)
return response.data?.list
}
static func requestHomeNew(page: Int) async -> [XSShortModel]? {
var parameters = XSNetwork.Parameters(path: "/newShortPlayList")
parameters.method = .get
parameters.parameters = [
"current_page" : page,
"page_size" : 20
]
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
return response.data?.list
}
static func requestHomeRankings() async -> [XSShortModel]? {
var parameters = XSNetwork.Parameters(path: "/nDayMaxRechargeShortPlayRank")
parameters.method = .get
parameters.parameters = [
"day" : 30,
]
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
return response.data?.list
}
static func requestCategoryList() async -> [XSCategoryModel]? {
var parameters = XSNetwork.Parameters(path: "/getCategories")
parameters.method = .get
let response: XSNetwork.Response<XSNetwork.List<XSCategoryModel>> = await XSNetwork.request(parameters: parameters)
return response.data?.list
}
static func requestCategoryVideo(id: String, page: Int) async -> [XSShortModel]? {
var parameters = XSNetwork.Parameters(path: "/videoList")
parameters.method = .get
parameters.parameters = [
"category_id" : id,
"current_page" : page,
"page_size" : 20
]
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
return response.data?.list
}
static func requestDiscoverData(page: Int) async -> [XSShortModel]? {
var parameters = XSNetwork.Parameters(path: "/getRecommands")
parameters.method = .get
parameters.parameters = [
"current_page" : page,
"page_size" : 20
]
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
return response.data?.list
}
///
static func requestSearch(text: String) async -> [XSShortModel]? {
var parameters = XSNetwork.Parameters(path: "/search")
parameters.method = .get
parameters.parameters = [
"search" : text
]
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
return response.data?.list
}
static func requestHotSearchList() async -> [XSShortModel]? {
var parameters = XSNetwork.Parameters(path: "/search/hots")
parameters.method = .get
parameters.isLoding = false
parameters.isToast = false
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: parameters)
return response.data?.list
}
static func requestTopSearchList() async -> [XSShortModel]? {
var parameters = XSNetwork.Parameters(path: "/getVisitTop")
parameters.method = .get
parameters.isLoding = false
parameters.isToast = false
let response: XSNetwork.Response<[XSShortModel]> = await XSNetwork.request(parameters: parameters)
return response.data
}
}

View File

@ -0,0 +1,20 @@
//
// XSSettingAPI.swift
// XSeri
//
// Created by 鸿 on 2026/2/28.
//
struct XSSettingAPI {
static func requestFeedbackRedCount() async -> XSFeedbackCountModel? {
var param = XSNetwork.Parameters(path: "/noticeNum")
param.method = .post
param.isLoding = false
param.isToast = false
let response: XSNetwork.Response<XSFeedbackCountModel> = await XSNetwork.request(parameters: param)
return response.data
}
}

View File

@ -0,0 +1,20 @@
//
// XSUserAPI.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
struct XSUserAPI {
static func requestUserInfo() async -> XSUserInfo? {
var parameters = XSNetwork.Parameters(path: "/customer/info")
parameters.method = .get
let response: XSNetwork.Response<XSUserInfo> = await XSNetwork.request(parameters: parameters)
return response.data
}
}

View File

@ -0,0 +1,116 @@
//
// XSVideoAPI.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
struct XSVideoAPI {
static func requestShortDetail(shortPlayId: String) async -> (XSShortDetailModel?, Int?, String?) {
let parameters: [String : Any] = [
"short_play_id" : shortPlayId,
"video_id" : "0"
]
var param = XSNetwork.Parameters(path: "/getVideoDetails")
param.method = .get
param.isToast = true
param.parameters = parameters
let response: XSNetwork.Response<XSShortDetailModel> = await XSNetwork.request(parameters: param)
if response.isSuccess {
return (response.data, response.code, response.msg)
} else {
return (nil, response.code, response.msg)
}
}
static func requestAddPlayHistory(shortPlayId: String?, videoId: String?) async {
let parameters: [String : Any] = [
"short_play_id" : shortPlayId ?? "0",
"video_id" : videoId ?? "0"
]
var param = XSNetwork.Parameters(path: "/createHistory")
param.method = .post
param.isToast = false
param.isLoding = false
param.parameters = parameters
let _: XSNetwork.Response<String> = await XSNetwork.request(parameters: param)
}
static func requestPlayHistorys(page: Int, pageSize: Int = 20) async -> [XSShortModel]? {
let parameters: [String : Any] = [
"current_page" : page,
"page_size" : pageSize
]
var param = XSNetwork.Parameters(path: "/myHistorys")
param.method = .get
param.parameters = parameters
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: param)
if response.isSuccess {
return response.data?.list
} else {
return nil
}
}
static func requestCollectShort(isCollect: Bool, shortId: String, videoId: String?) async -> Bool {
let path: String
if isCollect {
path = "/collect"
} else {
path = "/cancelCollect"
}
var parameters: [String : Any] = [
"short_play_id" : shortId,
]
if let videoId = videoId {
parameters["video_id"] = videoId
}
var param = XSNetwork.Parameters(path: path)
param.method = .post
param.isLoding = true
param.parameters = parameters
let response: XSNetwork.Response<String> = await XSNetwork.request(parameters: param)
if response.isSuccess {
await MainActor.run {
NotificationCenter.default.post(name: XSVideoAPI.updateShortCollectStateNotification, object: nil, userInfo: [
"state" : isCollect,
"id" : shortId,
])
}
return true
} else {
return false
}
}
static func requestCollectList(page: Int, pageSize: Int = 20) async -> [XSShortModel]? {
let parameters: [String : Any] = [
"current_page" : page,
"page_size" : pageSize
]
var param = XSNetwork.Parameters(path: "/myCollections")
param.method = .get
param.parameters = parameters
let response: XSNetwork.Response<XSNetwork.List<XSShortModel>> = await XSNetwork.request(parameters: param)
if response.isSuccess {
return response.data?.list
} else {
return nil
}
}
}
extension XSVideoAPI {
/// [ "state" : isFavorite, "id" : shortPlayId,]
static let updateShortCollectStateNotification = Notification.Name(rawValue: "XSVideoAPI.updateShortCollectStateNotification")
}

View File

@ -0,0 +1,102 @@
//
// XSCryptorService.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
struct XSCryptorService {
//
static let EN_STR_TAG: String = "$" //
//
static func decrypt(data: String) -> String {
guard data.hasPrefix(EN_STR_TAG) else {
// fatalError("Invalid encoded string")
return data
}
let decryptedData = deStrBytes(data: data)
return String(data: decryptedData, encoding: .utf8) ?? ""
}
//
static func deStrBytes(data: String) -> Data {
let hexData = String(data.dropFirst())
var bytes = Data()
var index = hexData.startIndex
while index < hexData.endIndex {
let nextIndex = hexData.index(index, offsetBy: 2, limitedBy: hexData.endIndex) ?? hexData.endIndex
let byteString = String(hexData[index..<nextIndex])
if let byte = UInt8(byteString, radix: 16) {
bytes.append(byte)
}
index = nextIndex
}
return de(data: bytes)
}
//
static func de(data: Data) -> Data {
guard !data.isEmpty else {
return data
}
let saltLen = Int(data[data.startIndex])
guard data.count >= 1 + saltLen else {
return data
}
let salt = data.subdata(in: 1..<1+saltLen)
let encryptedData = data.subdata(in: 1+saltLen..<data.count)
return deWithSalt(data: encryptedData, salt: salt)
}
// 使
static func deWithSalt(data: Data, salt: Data) -> Data {
let decryptedData = cxEd(data: data)
return removeSalt(data: decryptedData, salt: salt)
}
// /
static func cxEd(data: Data) -> Data {
return Data(data.map { $0 ^ 0xFF })
}
//
static func removeSalt(data: Data, salt: Data) -> Data {
guard !salt.isEmpty else {
return data
}
var result = Data()
let saltBytes = [UInt8](salt)
let saltCount = saltBytes.count
for (index, byte) in data.enumerated() {
let saltByte = saltBytes[index % saltCount]
let decryptedByte = calRemoveSalt(v: byte, s: saltByte)
result.append(decryptedByte)
}
return result
}
//
static func calRemoveSalt(v: UInt8, s: UInt8) -> UInt8 {
if v >= s {
return v - s
} else {
return UInt8(0xFF) - (s - v) + 1
}
}
}

View File

@ -0,0 +1,186 @@
//
// XSNetwork.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
import Moya
import SmartCodable
let kXSRegisterPath = "/customer/register"
///
struct XSNetwork {
/// Moya HUD
static let provider = MoyaProvider<XSNetwork.Target>(plugins: [XSNetworkHUDPlugin()])
/// Token
/// 使 _Concurrency.Task Moya.Task
private static var refreshTokenTask: _Concurrency.Task<Bool, Never>?
// MARK: - Public Request Method
///
/// - Parameter parameters:
/// - Returns: Response
static func request<T: SmartCodable>(parameters: XSNetwork.Parameters) async -> XSNetwork.Response<T> {
// 1. Token ( Token )
if XSLoginManager.manager.token == nil && parameters.path != kXSRegisterPath {
_ = await self.requestToken()
}
// 2.
let result = await provider.requestAsync(.request(parameters: parameters))
// 3.
switch result {
case .success(let response):
let code = response.statusCode
// 4. Token (401, 402, 403)
if (code == 401 || code == 402 || code == 403) && parameters.path != kXSRegisterPath {
// 402
if code == 402 && parameters.isToast {
await MainActor.run { XSToast.show("network_error_1".localized) }
}
// Token
let success = await self.requestToken()
if success {
//
return await self.request(parameters: parameters)
}
}
// 5.
return self.handleResponse(response, parameters: parameters)
case .failure(let error):
// 6.
xsLog("Network Error: \(error)")
if parameters.isToast {
await MainActor.run { XSToast.show("network_error_2".localized) }
}
return self.createErrorResponse(msg: "network_error_2".localized)
}
}
// MARK: - Private Helper Methods
///
private static func handleResponse<T: SmartCodable>(_ response: Moya.Response, parameters: XSNetwork.Parameters) -> XSNetwork.Response<T> {
do {
let tempData = try response.mapString()
xsLog("Path: \(parameters.path)")
xsLog("Params: \(parameters.parameters ?? [:])")
let networkResponse: XSNetwork.Response<T> = self._deserialize(data: tempData)
// ( code != 200)
if !networkResponse.isSuccess && parameters.isToast {
let msg = networkResponse.msg ?? "Error".localized
DispatchQueue.main.async {
XSToast.show(msg)
}
}
return networkResponse
} catch {
if parameters.isToast {
DispatchQueue.main.async { XSToast.show("Error".localized) }
}
return self.createErrorResponse(msg: "Error".localized)
}
}
///
private static func _deserialize<T: SmartCodable>(data: String) -> XSNetwork.Response<T> {
let decrypted = XSCryptorService.decrypt(data: data)
xsLog("Decrypted Response: \(decrypted)")
var response = XSNetwork.Response<T>.deserialize(from: decrypted)
response?.rawData = decrypted
if let response = response {
return response
} else {
return self.createErrorResponse(msg: "Error".localized)
}
}
///
private static func createErrorResponse<T: SmartCodable>(code: Int = -1, msg: String) -> XSNetwork.Response<T> {
var res = XSNetwork.Response<T>()
res.code = code
res.msg = msg
return res
}
// MARK: - Token Management
/// / Token
@discardableResult
@MainActor
static func requestToken() async -> Bool {
//
if let existingTask = refreshTokenTask {
return await existingTask.value
}
// _Concurrency.Task
let task = _Concurrency.Task { () -> Bool in
let param = XSNetwork.Parameters(path: kXSRegisterPath, isLoding: false, isToast: false)
let response: XSNetwork.Response<XSLoginToken> = await self.request(parameters: param)
if let token = response.data {
XSLoginManager.manager.setAccountToken(token)
_Concurrency.Task {
await XSLoginManager.manager.updateUserInfo()
}
return true
}
return false
}
self.refreshTokenTask = task
//
let result = await task.value
// 便
self.refreshTokenTask = nil
return result
}
}
// MARK: - Moya Async Extension
extension MoyaProvider {
/// Moya Async/Await
func requestAsync(_ target: Target) async -> Result<Moya.Response, MoyaError> {
await withCheckedContinuation { continuation in
self.request(target) { result in
continuation.resume(returning: result)
}
}
}
}
// MARK: - Backward Compatibility
extension XSNetwork {
/// async 便
static func request<T: SmartCodable>(parameters: XSNetwork.Parameters, completion: ((_ response: XSNetwork.Response<T>) -> Void)?) {
_Concurrency.Task {
let response: XSNetwork.Response<T> = await self.request(parameters: parameters)
await MainActor.run {
completion?(response)
}
}
}
}

View File

@ -0,0 +1,75 @@
//
// XSNetworkModel.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import Moya
import SmartCodable
import Alamofire
extension XSNetwork {
///
static let SuccessCode = 200
///
struct Parameters {
var path: String
var parameters: [String : Any]?
var method: Moya.Method = .post
var isLoding: Bool = false
var isToast: Bool = true
init(path: String,
parameters: [String : Any]? = nil,
method: Moya.Method = .post,
isLoding: Bool = false,
isToast: Bool = true) {
self.path = path
self.parameters = parameters
self.method = method
self.isLoding = isLoding
self.isToast = isToast
}
}
///
struct Response<T: SmartCodable>: SmartCodable {
var data: T?
var code: Int?
var msg: String?
///
@IgnoredKey
var rawData: Any?
///
var isSuccess: Bool {
return code == XSNetwork.SuccessCode
}
}
///
struct List<T: SmartCodable>: SmartCodable {
var list: [T]?
var pagination: Pagination?
}
///
struct Pagination: SmartCodable {
var current_page: Int?
var page_size: Int?
var page_total: Int?
var total_size: Int?
/// SmartCodable 线
enum CodingKeys: String, CodingKey {
case current_page = "current_page"
case page_size = "page_size"
case page_total = "page_total"
case total_size = "total_size"
}
}
}

View File

@ -0,0 +1,76 @@
//
// XSNetworkMonitorManager.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
import Network
class XSNetworkMonitorManager: NSObject {
static let manager = XSNetworkMonitorManager()
///
var isReachable: Bool?
private var connectionType: NWInterface.InterfaceType?
private var status: NWPath.Status?
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitorQueue")
func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
self.status = path.status
if path.usesInterfaceType(.wifi) {
self.connectionType = .wifi
} else if path.usesInterfaceType(.cellular) {
self.connectionType = .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
self.connectionType = .wiredEthernet
} else {
self.connectionType = nil
}
if path.status == .satisfied, self.connectionType != nil {
if self.isReachable == false {
self.isReachable = true
DispatchQueue.main.async {
NotificationCenter.default.post(name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
}
} else {
self.isReachable = true
}
} else {
if self.isReachable == true {
self.isReachable = false
DispatchQueue.main.async {
NotificationCenter.default.post(name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
}
} else {
self.isReachable = false
}
}
}
monitor.start(queue: queue)
}
func stopMonitoring() {
monitor.cancel()
}
}
extension XSNetworkMonitorManager {
///
static let networkStatusDidChangeNotification = Notification.Name(rawValue: "XSNetworkMonitorManager.networkStatusDidChangeNotification")
}

View File

@ -0,0 +1,42 @@
//
// XSNetworkPlugin.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import Foundation
import Moya
///
/// HUD UI
struct XSNetworkHUDPlugin: PluginType {
func willSend(_ request: RequestType, target: TargetType) {
guard let target = target as? XSNetwork.Target,
case let .request(parameters) = target else {
return
}
// loading线 HUD
if parameters.isLoding {
DispatchQueue.main.async {
XSHud.show()
}
}
}
func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
guard let target = target as? XSNetwork.Target,
case let .request(parameters) = target else {
return
}
// loading HUD
if parameters.isLoding {
DispatchQueue.main.async {
XSHud.dismiss()
}
}
}
}

View File

@ -0,0 +1,89 @@
//
// XSNetworkTarget.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import Moya
import SmartCodable
import AdSupport
import YYCategories
extension XSNetwork {
enum Target {
case request(parameters: XSNetwork.Parameters)
}
}
extension XSNetwork.Target: TargetType {
var baseURL: URL {
return .init(string: XSBaseURL)!
}
var path: String {
switch self {
case .request(let parameters):
return XSURLPathPrefix + parameters.path
}
}
var method: Moya.Method {
switch self {
case .request(let parameters):
return parameters.method
}
}
var task: Moya.Task {
switch self {
case .request(let parameters):
let params = parameters.parameters ?? [:]
return .requestParameters(parameters: params, encoding: getEncoding())
}
}
var headers: [String : String]? {
let userToken = XSLoginManager.manager.token?.token ?? ""
let dic: [String : String] = [
"system-version" : kXSSystemVersion,
"lang-key" : "en", //
"time-zone" : String.timeZone(), //
"app-version" : kXSVersion,
"device-id" : XSDeviceId.shared.id, // id
"brand" : "apple", //
"app-name" : kXSBundleName,
"system-type" : "ios",
"idfa" : ASIdentifierManager.shared().advertisingIdentifier.uuidString,
"model" : UIDevice.current.machineModelName ?? "",
"authorization" : userToken,
"device-gaid" : UIDevice.current.identifierForVendor?.uuidString ?? "",
"product-prefix" : "XSeri"
]
return dic
}
var sampleData: Data {
return "".data(using: .utf8)!
}
}
private extension XSNetwork.Target {
func getEncoding() -> ParameterEncoding {
switch self.method {
case .get, .delete:
return URLEncoding.default
default:
return JSONEncoding.default
}
}
}
extension String {
static func timeZone() -> String {
let timeZone = NSTimeZone.local as NSTimeZone
let seconds = timeZone.secondsFromGMT / 3600
let sign = seconds >= 0 ? "+" : "-"
return String(format: "GMT%@%02d:00", sign, abs(seconds))
}
}

View File

@ -0,0 +1,25 @@
//
// XSURLPath.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
let XSBaseURL = "https://api-breeltv.breeltv.com"
let XSURLPathPrefix = "/reel"
let XSWebBaseURL = "https://www.breeltv.com"
let XSCampaignWebURL = "https://campaign.breeltv.com"
///
let kXSUserAgreementWebUrl = XSWebBaseURL + "/user_policy"
///
let kXSPrivacyPolicyWebUrl = XSWebBaseURL + "/private"
///
let kXSFeedBackHomeWebUrl = XSCampaignWebURL + "/pages/leave/index"
///
let kXSFeedBackListWebUrl = XSCampaignWebURL + "/pages/leave/list"
///
let kXSFeedBackDetailWebUrl = XSCampaignWebURL + "/pages/leave/detail"
///
let kXSLogoutWebUrl = XSCampaignWebURL + "/pages/setting/logout"

View File

@ -0,0 +1,56 @@
//
// XSButton.swift
// XSeri
//
// Created by 鸿 on 2026/2/25.
//
import UIKit
class XSButton: UIButton {
override class var layerClass: AnyClass {
return CAGradientLayer.self
}
var xs_gradientLayer: CAGradientLayer {
return self.layer as! CAGradientLayer
}
var xs_colors: [CGColor]? {
get {
return xs_gradientLayer.colors as? [CGColor]
}
set {
xs_gradientLayer.colors = newValue
}
}
var xs_startPoint: CGPoint {
get {
return xs_gradientLayer.startPoint
}
set {
xs_gradientLayer.startPoint = newValue
}
}
var xs_endPoint: CGPoint {
get {
return xs_gradientLayer.endPoint
}
set {
xs_gradientLayer.endPoint = newValue
}
}
var xs_locations: [NSNumber]? {
get {
return xs_gradientLayer.locations
}
set {
xs_gradientLayer.locations = newValue
}
}
}

View File

@ -0,0 +1,23 @@
//
// XSCollectionView.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
class XSCollectionView: UICollectionView {
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
self.backgroundColor = .clear
self.contentInsetAdjustmentBehavior = .never
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,20 @@
//
// XSCustomTabBar.swift
// XSeri
//
// Created by 鸿 on 2026/01/12.
//
import UIKit
import ESTabBarController_swift
final class XSCustomTabBar: ESTabBar {
var contentHeight: CGFloat = 60
override func sizeThatFits(_ size: CGSize) -> CGSize {
var result = super.sizeThatFits(size)
result.height = contentHeight + XSScreen.safeBottom
return result
}
}

View File

@ -0,0 +1,98 @@
//
// XSImageView.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import Kingfisher
class XSImageView: UIImageView {
var placeholderColor = UIColor.black
var placeholderImage = UIImage(named: "placeholder_image")
private lazy var placeholderImageView: UIImageView = {
let imageView = UIImageView(image: placeholderImage)
imageView.isHidden = true
imageView.contentMode = .scaleAspectFit
return imageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_init()
}
override init(image: UIImage?) {
super.init(image: image)
xs_init()
}
override init(image: UIImage?, highlightedImage: UIImage?) {
super.init(image: image, highlightedImage: highlightedImage)
xs_init()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
xs_init()
}
override func awakeFromNib() {
super.awakeFromNib()
xs_init()
}
func xs_init() {
self.contentMode = .scaleAspectFill
self.layer.masksToBounds = true
if image == nil {
self.backgroundColor = self.placeholderColor
placeholderImageView.isHidden = false
}
addSubview(placeholderImageView)
}
override var image: UIImage? {
didSet {
if self.backgroundColor == nil && image == nil {
self.backgroundColor = self.placeholderColor
} else if image != nil {
if self.backgroundColor == self.placeholderColor {
self.backgroundColor = nil
}
}
if image == nil {
placeholderImageView.isHidden = false
} else {
placeholderImageView.isHidden = true
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
placeholderImageView.frame = .init(x: 0, y: 0, width: self.bounds.width, height: self.bounds.height)
placeholderImageView.center = .init(x: self.bounds.width / 2, y: self.bounds.height / 2)
}
}
extension UIImageView {
func xs_setImage(_ url: String?, placeholder: UIImage? = nil, completer: ((_ image: UIImage?, _ url: URL?) -> Void)? = nil) {
self.kf.setImage(with: URL(string: url ?? ""), placeholder: placeholder, options: nil) { result in
switch result {
case .success(let value):
completer?(value.image, value.source.url)
default :
completer?(nil, nil)
break
}
}
}
}

View File

@ -0,0 +1,56 @@
//
// XSLabel.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
class XSLabel: UILabel {
var textColors: [CGColor]?
var textStartPoint: CGPoint?
var textEndPoint: CGPoint?
var colorImage: UIImage?
override func layoutSubviews() {
super.layoutSubviews()
let size = self.bounds.size
if let text = self.text, text.count > 0, let colors = self.textColors, let startPoint = self.textStartPoint, let endPoine = self.textEndPoint {
self.textColor = UIColor(patternImage: UIImage.xs_getGradientImage(size: size, colors: colors, startPoint: startPoint, endPoint: endPoine))
} else if let image = self.colorImage {
self.textColor = UIColor(patternImage: image.xs_resized(to: size))
}
}
}
extension UIImage {
static func xs_getGradientImage(size: CGSize, colors: [CGColor], startPoint: CGPoint, endPoint: CGPoint) -> UIImage {
UIGraphicsBeginImageContextWithOptions(size, false, UIScreen.main.scale)
guard let context = UIGraphicsGetCurrentContext() else{return UIImage()}
let colorSpace = CGColorSpaceCreateDeviceRGB()
///
let gradientRef = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: nil)!
let startPoint = CGPoint(x: size.width * startPoint.x, y: size.height * startPoint.y)
let endPoint = CGPoint(x: size.width * endPoint.x, y: size.height * endPoint.y)
context.drawLinearGradient(gradientRef, start: startPoint, end: endPoint, options: CGGradientDrawingOptions(arrayLiteral: .drawsBeforeStartLocation,.drawsAfterEndLocation))
let gradientImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return gradientImage ?? UIImage()
}
func xs_resized(to size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
self.draw(in: CGRect(origin: .zero, size: size))
}
}
}

View File

@ -0,0 +1,76 @@
//
// XSPanModalContentView.swift
// XSeri
//
// Created by 鸿 on 2026/2/26.
//
import UIKit
import HWPanModal
class XSPanModalContentView: HWPanModalContentView {
var contentHeight = XSScreen.height * (2 / 3)
var mainScrollView: UIScrollView?
///UI contentSize
func setNeedsLayoutUpdate() {
self.panModalSetNeedsLayoutUpdate()
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//MARK: HWPanModalPresentable
override func panScrollable() -> UIScrollView? {
return mainScrollView
}
override func longFormHeight() -> PanModalHeight {
return PanModalHeightMake(.content, contentHeight)
}
override func showDragIndicator() -> Bool {
return false
}
override func backgroundConfig() -> HWBackgroundConfig {
let config = HWBackgroundConfig()
config.backgroundAlpha = 0.6
return config
}
override func allowsTapBackgroundToDismiss() -> Bool {
return true
}
override func allowsDragToDismiss() -> Bool {
return false
}
override func allowsPullDownWhenShortState() -> Bool {
return false
}
override func showsScrollableVerticalScrollIndicator() -> Bool {
return false
}
override func springDamping() -> CGFloat {
return 1
}
override func cornerRadius() -> CGFloat {
return 18
}
}

View File

@ -0,0 +1,244 @@
//
// XSProgressView.swift
// XSeri
//
// Created by 鸿 on 2026/1/12.
//
import UIKit
import YYCategories
import YYText
class XSProgressView: UIView {
///
var panStart: (() -> Void)?
///
var panChange: ((_ progress: CGFloat) -> Void)?
///
var panFinish: ((_ progress: CGFloat) -> Void)?
var progress: CGFloat = 0 {
didSet {
if !isPaning {
setNeedsDisplay()
}
}
}
///
private var tempProgress: CGFloat = 0
///
private var panProgress: CGFloat = 0
var progressColor: UIColor = .FFDAA_4.withAlphaComponent(0.39) {
didSet {
setNeedsDisplay()
}
}
var currentProgress: UIColor = .FFDAA_4 {
didSet {
setNeedsDisplay()
}
}
var lineWidth: CGFloat = 2 {
didSet {
setNeedsDisplay()
}
}
///
var isLoading = false {
didSet {
if isLoading {
if gradientTimer == nil {
gradientTimer = Timer.scheduledTimer(timeInterval: 0.05, target: YYTextWeakProxy(target: self), selector: #selector(handleGradientTimer), userInfo: nil, repeats: true)
}
} else {
gradientTimer?.invalidate()
gradientTimer = nil
}
}
}
var thumbImage: UIImage? {
didSet {
setNeedsDisplay()
}
}
var insets: UIEdgeInsets = .init(top: 0, left: 15, bottom: 0, right: 15) {
didSet {
self.invalidateIntrinsicContentSize()
setNeedsDisplay()
}
}
private(set) lazy var panGesture: UIPanGestureRecognizer = {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(sender:)))
return pan
}()
private(set) lazy var tagGesture: UITapGestureRecognizer = {
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(sender:)))
return tap
}()
///
private var isPaning: Bool = false
private var gradientTimer: Timer?
private var gradientValue: CGFloat = 0
override var intrinsicContentSize: CGSize {
return .init(width: XSScreen.width, height: lineWidth + insets.top + insets.bottom)
}
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .clear
self.addGestureRecognizer(panGesture)
self.addGestureRecognizer(tagGesture)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
setNeedsDisplay()
}
@objc private func handleGradientTimer() {
gradientValue += 0.1
if gradientValue > 1 {
gradientValue = 0
}
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
let width = rect.width
let thumbImageSize = self.thumbImage?.size ?? .zero
let progressX = insets.left + thumbImageSize.width / 2
let progressY = insets.top
let progressWidth = width - insets.left - insets.right - thumbImageSize.width
if isLoading, !isPaning {
//
let colorSpace = CGColorSpaceCreateDeviceRGB()
let colors: [CGColor] = [
UIColor.clear.cgColor,
UIColor.white.cgColor,
UIColor.clear.cgColor
]
let locations: [CGFloat] = [0.0, gradientValue, 1.0]
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: locations) else {
return
}
let gradientRect = CGRect(x: progressX,
y: progressY,
width: progressWidth,
height: lineWidth)
//
let startPoint = CGPoint(x: rect.minX, y: rect.minY)
let endPoint = CGPoint(x: rect.maxX, y: rect.maxY)
//
context.saveGState()
context.clip(to: gradientRect)
//
context.drawLinearGradient(gradient, start: startPoint, end: endPoint, options: [])
} else {
var progress = self.progress
if self.isPaning {
progress = self.panProgress
}
if progress < 0 {
progress = 0
}
if progress > 1 {
progress = 1
}
///
let progressPath = UIBezierPath(roundedRect: CGRect(x: progressX, y: progressY, width: progressWidth, height: lineWidth), cornerRadius: lineWidth / 2)
context.addPath(progressPath.cgPath)
context.setFillColor(progressColor.cgColor)
context.fillPath()
///
let currentPath = UIBezierPath(roundedRect: CGRect(x: progressX, y: progressY, width: progressWidth * progress, height: lineWidth), cornerRadius: lineWidth / 2)
context.addPath(currentPath.cgPath)
context.setFillColor(currentProgress.cgColor)
context.fillPath()
if let thumbImage = thumbImage {
let size = thumbImage.size
let frame = CGRect(x: progressWidth * progress - size.width / 2 + progressX, y: progressY - size.width / 2 + lineWidth / 2, width: size.width, height: size.height)
thumbImage.draw(in: frame)
}
}
}
}
extension XSProgressView {
@objc func handlePanGesture(sender: UIPanGestureRecognizer) {
switch sender.state {
case .began:
self.isPaning = true
self.tempProgress = self.progress
sender.setTranslation(CGPoint(x: 0, y: 0), in: self)
self.panStart?()
case .changed:
let point = sender.translation(in: self)
let offsetX = point.x / (self.width - self.insets.left - self.insets.right)
self.panProgress = self.tempProgress + offsetX
if self.panProgress < 0 {
self.panProgress = 0
}
if self.panProgress > 1 {
self.panProgress = 1
}
self.panChange?(self.panProgress)
setNeedsDisplay()
default:
self.isPaning = false
self.panFinish?(self.panProgress)
self.panProgress = 0
}
}
@objc func handleTapGesture(sender: UITapGestureRecognizer) {
let point = sender.location(in: self)
let offsetX = (point.x - self.insets.left) / (self.width - self.insets.left - self.insets.right)
self.panFinish?(offsetX)
}
}

View File

@ -0,0 +1,83 @@
import UIKit
import ESTabBarController_swift
class XSTabBarItemContentView: ESTabBarItemContentView {
override init(frame: CGRect) {
super.init(frame: frame)
//
textColor = XSConfig.Color.tabBarInactive
iconColor = XSConfig.Color.tabBarInactive
//
highlightTextColor = XSConfig.Color.tabBarActive
highlightIconColor = XSConfig.Color.tabBarActive
//
titleLabel.numberOfLines = 1
titleLabel.textAlignment = .center
// ImageView
imageView.contentMode = .scaleAspectFit
}
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateLayout() {
super.updateLayout()
// 1. ( Figma 36pt 24pt)
let iconSize: CGFloat = selected ? 36.0 : 24.0
//
let iconY: CGFloat = selected ? 6.0 : 10.0
imageView.frame = CGRect(x: (bounds.size.width - iconSize) / 2.0,
y: iconY,
width: iconSize,
height: iconSize)
// 2.
titleLabel.font = selected ? XSConfig.Font.tabBarActive : XSConfig.Font.tabBarInactive
titleLabel.sizeToFit()
//
let labelWidth = bounds.size.width + 20
titleLabel.frame = CGRect(x: (bounds.size.width - labelWidth) / 2.0,
y: imageView.frame.maxY + 2.0,
width: labelWidth,
height: 14.0)
}
override func selectAnimation(animated: Bool, completion: (() -> Void)?) {
super.selectAnimation(animated: animated, completion: completion)
if animated {
//
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut], animations: {
self.updateLayout()
}, completion: { _ in
completion?()
})
} else {
self.updateLayout()
completion?()
}
}
override func deselectAnimation(animated: Bool, completion: (() -> Void)?) {
super.deselectAnimation(animated: animated, completion: completion)
if animated {
//
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseIn], animations: {
self.updateLayout()
}, completion: { _ in
completion?()
})
} else {
self.updateLayout()
completion?()
}
}
}

View File

@ -0,0 +1,49 @@
//
// XSTableView.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
class XSTableView: UITableView {
var insetGroupedMargins: CGFloat = 15
override init(frame: CGRect, style: UITableView.Style) {
super.init(frame: frame, style: style)
separatorColor = .white.withAlphaComponent(0.1)
separatorInset = .init(top: 0, left: 16, bottom: 0, right: 16)
self.backgroundColor = .clear
self.contentInsetAdjustmentBehavior = .never
if style == .insetGrouped {
sectionFooterHeight = 14
sectionHeaderHeight = 0.1
} else if style == .plain {
if #available(iOS 15.0, *) {
sectionHeaderTopPadding = 0
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var layoutMargins: UIEdgeInsets {
set {
super.layoutMargins = newValue
}
get {
var margins = super.layoutMargins
if self.style == .insetGrouped {
margins.left = self.safeAreaInsets.left + insetGroupedMargins
margins.right = self.safeAreaInsets.right + insetGroupedMargins
}
return margins
}
}
}

View File

@ -0,0 +1,49 @@
//
// XSTableViewCell.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
class XSTableViewCell: UITableViewCell {
private(set) lazy var xs_indicatorImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "arrow_right_icon_01"))
return imageView
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
self.layer.rasterizationScale = UIScreen.main.scale
self.layer.shouldRasterize = true
self.selectionStyle = .none
self.backgroundColor = .clear
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
}
extension UITableViewCell {
var br_tableView: UITableView? {
return self.value(forKey: "_tableView") as? UITableView
}
}

View File

@ -0,0 +1,56 @@
//
// XSView.swift
// XSeri
//
// Created by 鸿 on 2026/2/25.
//
import UIKit
class XSView: UIView {
override class var layerClass: AnyClass {
return CAGradientLayer.self
}
var xs_gradientLayer: CAGradientLayer {
return self.layer as! CAGradientLayer
}
var xs_colors: [CGColor]? {
get {
return xs_gradientLayer.colors as? [CGColor]
}
set {
xs_gradientLayer.colors = newValue
}
}
var xs_startPoint: CGPoint {
get {
return xs_gradientLayer.startPoint
}
set {
xs_gradientLayer.startPoint = newValue
}
}
var xs_endPoint: CGPoint {
get {
return xs_gradientLayer.endPoint
}
set {
xs_gradientLayer.endPoint = newValue
}
}
var xs_locations: [NSNumber]? {
get {
return xs_gradientLayer.locations
}
set {
xs_gradientLayer.locations = newValue
}
}
}

View File

@ -0,0 +1,78 @@
//
// XSAppWebViewController.swift
// XSeri
//
// Created by 鸿 on 2026/2/28.
//
import UIKit
class XSAppWebViewController: XSBaseWebViewController {
var id: String?
private var receiveDataCount = 0
var theme: String? = "theme_3"
override func viewDidLoad() {
super.viewDidLoad()
self.autoTitle = false
if webUrl == kXSFeedBackListWebUrl {
self.title = "feedback_history".localized
} else if webUrl == kXSFeedBackHomeWebUrl {
self.title = "feedback".localized
} else if webUrl == kXSFeedBackDetailWebUrl {
self.title = "feedback_detail".localized
} else if webUrl == kXSLogoutWebUrl {
self.title = "account_deletion".localized
}
}
override func xs_webViewDidFinishLoad(_ webView: XSWebView) {
super.xs_webViewDidFinishLoad(webView)
receiveDataCount = 0
receiveDataFromNative()
}
}
extension XSAppWebViewController {
func receiveDataFromNative() {
receiveDataCount += 1
if receiveDataCount > 10 { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self = self else { return }
var dic = [
"token" : XSLoginManager.manager.token?.token ?? "",
"time_zone" : String.timeZone(),
"lang" : "en",
"type" : "ios",
"device-id" : XSDeviceId.shared.id
]
if let theme = theme {
dic["theme"] = theme
}
if let id = id {
dic["id"] = id
}
if let json = dic.toJsonString() {
let js = "receiveDataFromNative(\(json))"
self.webView.evaluateJavaScript(js) { [weak self] _, error in
guard let self = self else { return }
if error != nil {
self.receiveDataFromNative()
}
}
}
}
}
}

View File

@ -0,0 +1,111 @@
//
// XSBaseWebViewController+Script.swift
// XSeri
//
// Created by 鸿 on 2026/2/28.
//
import UIKit
import WebKit
import ZLPhotoBrowser
import SmartCodable
///APP
let kXSWebMessageAPP = "js2app"
///
let kXSWebMessageOpenFeedbackList = "openFeedbackList"
///
let kXSWebMessageOpenFeedbackDetail = "openFeedbackDetail"
///
let kXSWebMessageOpenPhotoPicker = "openPhotoPicker"
///
let kXSWebMessageAccountDeletionFinish = "accountLogout"
extension XSBaseWebViewController {
func xs_webViewUserContentController(didReceive message: WKScriptMessage) {
let name = message.name
let body = message.body
switch name {
case kXSWebMessageOpenFeedbackList:
let vc = XSAppWebViewController()
vc.webUrl = kXSFeedBackListWebUrl
self.navigationController?.pushViewController(vc, animated: true)
case kXSWebMessageOpenFeedbackDetail:
guard let body = body as? [String : Any] else { return }
guard let id = body["id"] as? Int else { return }
let vc = XSAppWebViewController()
vc.id = "\(id)"
vc.webUrl = kXSFeedBackDetailWebUrl
self.navigationController?.pushViewController(vc, animated: true)
case kXSWebMessageOpenPhotoPicker:
openPhotoPicker()
case kXSWebMessageAPP:
guard let body = message.body as? [String : Any] else { return }
guard let model = XSWebMessageModel.deserialize(from: body) else { return }
let type = model.type
let data = model.data
if type == "login" {
// let view = FALoginView()
// view.present(in: nil)
} else if type == "open_notify" {
// openNotify()
} else if type == "watch_video" {
let vc = XSShortDetailViewController()
vc.shortId = data?.short_play_id
vc.activityId = data?.activity_id
self.navigationController?.pushViewController(vc, animated: true)
} else {
guard let urlStr = data?.link else { return }
guard let url = URL(string: urlStr) else { return }
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
}
case kXSWebMessageAccountDeletionFinish:
self.navigationController?.popToRootViewController(animated: true)
NotificationCenter.default.post(name: XSLoginManager.loginStateDidChangeNotification, object: nil)
default:
break
}
}
///
private func openPhotoPicker() {
ZLPhotoConfiguration.default().allowSelectOriginal = false
ZLPhotoConfiguration.default().maxSelectCount = 1
ZLPhotoConfiguration.default().allowEditImage = false
ZLPhotoConfiguration.default().allowSelectVideo = false
ZLPhotoConfiguration.default().allowSelectGif = false
ZLPhotoConfiguration.default().allowTakePhotoInLibrary = false
let picker = ZLPhotoPicker()
picker.selectImageBlock = { [weak self] (results, _) in
guard let self = self else { return }
guard let image = results.first?.image else { return }
guard let imageData = image.jpegData(compressionQuality: 0.8) else { return }
let imageDataStr = imageData.base64EncodedString(options: .endLineWithCarriageReturn)
let js = "uploadConvertImage('\(imageDataStr)')"
self.webView.evaluateJavaScript(js)
}
picker.showPhotoLibrary(sender: self)
}
}

View File

@ -0,0 +1,113 @@
//
// XSBaseWebViewController.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
import WebKit
import SnapKit
class XSBaseWebViewController: XSViewController {
var webUrl: String?
///
var autoTitle = true
var needAutoRefresh = true
private(set) lazy var webView: XSWebView = {
let controller = WKUserContentController()
let config = WKWebViewConfiguration()
config.userContentController = controller
config.defaultWebpagePreferences.allowsContentJavaScript = true
config.preferences.javaScriptCanOpenWindowsAutomatically = false
let webView = XSWebView(frame: self.view.bounds, configuration: config)
webView.delegate = self
return webView
}()
override func viewDidLoad() {
super.viewDidLoad()
// self.edgesForExtendedLayout = []
xs_setupUI()
if let url = webUrl {
self.load(webUrl: url)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(false, animated: true)
self.xs_setNavigationStyle()
}
func load(webUrl: String) {
let str: String = webUrl
guard let url = URL(string: str) else { return }
let request = URLRequest(url: url, timeoutInterval: 30)
self.webView.load(request)
}
func reload() {
self.webView.reload()
}
}
extension XSBaseWebViewController {
private func xs_setupUI() {
self.view.addSubview(webView)
self.webView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(0)
make.right.equalToSuperview().offset(0)
make.bottom.equalToSuperview().offset(0)
make.top.equalTo(self.view.safeAreaLayoutGuide)
}
}
}
//MARK: -------------- VPWebViewDelegate --------------
extension XSBaseWebViewController: XSWebViewDelegate {
func xs_webView(_ webView: XSWebView, shouldStartLoadWith navigationAction: WKNavigationAction) -> Bool {
self.webView.isHidden = false
return true
}
func xs_webViewDidStartLoad(_ webView: XSWebView) {
XSHud.show(containerView: self.view)
}
func xs_webView(webView: XSWebView, didChangeTitle title: String) {
if autoTitle {
self.title = title
}
}
func xs_webViewDidFinishLoad(_ webView: XSWebView) {
self.webView.isHidden = false
XSHud.dismiss()
}
func xs_webView(_ webView: XSWebView, didFailLoadWithError error: any Error) {
XSHud.dismiss()
}
func xs_userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
self.xs_webViewUserContentController(didReceive: message)
}
}

View File

@ -0,0 +1,22 @@
//
// XSWebMessageModel.swift
// XSeri
//
// Created by 鸿 on 2026/2/28.
//
import UIKit
import SmartCodable
struct XSWebMessageModel: SmartCodable {
var type: String?
var data: XSWebMessageData?
}
struct XSWebMessageData: SmartCodable {
var activity_id: String?
var short_play_id: String?
var link: String?
}

View File

@ -0,0 +1,153 @@
//
// XSWebView.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
import WebKit
import YYText
//MARK:-------------- VPWebViewDelegate --------------
@objc protocol XSWebViewDelegate: NSObjectProtocol {
@objc optional func xs_webView(_ webView: XSWebView, shouldStartLoadWith navigationAction: WKNavigationAction) -> Bool
@objc optional func xs_webViewDidStartLoad(_ webView: XSWebView)
@objc optional func xs_webViewDidFinishLoad(_ webView: XSWebView)
@objc optional func xs_webView(_ webView: XSWebView, didFailLoadWithError error: Error)
///
@objc optional func xs_webView(webView: XSWebView, didChangeProgress progress: CGFloat)
///
@objc optional func xs_webView(webView: XSWebView, didChangeTitle title: String)
///web
@objc optional func xs_userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
}
class XSWebView: WKWebView {
weak var delegate: XSWebViewDelegate?
private(set) var scriptMessageHandlerArray: [String] = [
kXSWebMessageAPP,
kXSWebMessageOpenFeedbackList,
kXSWebMessageOpenFeedbackDetail,
kXSWebMessageOpenPhotoPicker,
kXSWebMessageAccountDeletionFinish,
]
deinit {
self.removeObserver(self, forKeyPath: "estimatedProgress")
self.removeObserver(self, forKeyPath: "title")
}
override init(frame: CGRect, configuration: WKWebViewConfiguration) {
super.init(frame: frame, configuration: configuration)
addScriptMessageHandler()
_setupInit()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func _setupInit() {
self.isOpaque = false
self.navigationDelegate = self
self.addObserver(self, forKeyPath: "estimatedProgress", options: .new, context: nil)
self.addObserver(self, forKeyPath: "title", options: .new, context: nil)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if object as? XSWebView == self {
if keyPath == "estimatedProgress", let progress = change?[NSKeyValueChangeKey.newKey] as? CGFloat {
self.delegate?.xs_webView?(webView: self, didChangeProgress: progress)
} else if keyPath == "title", let title = change?[NSKeyValueChangeKey.newKey] as? String {
self.delegate?.xs_webView?(webView: self, didChangeTitle: title)
}
}
}
func load(urlStr: String) {
guard let url = URL(string: urlStr) else { return }
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 30)
self.load(request)
}
func removeScriptMessageHandler() {
self.scriptMessageHandlerArray.forEach{
configuration.userContentController.removeScriptMessageHandler(forName: $0)
}
}
func addScriptMessageHandler() {
self.scriptMessageHandlerArray.forEach{
configuration.userContentController.add(YYTextWeakProxy(target: self) as! WKScriptMessageHandler, name: $0)
}
}
}
//MARK:-------------- WKNavigationDelegate --------------
extension XSWebView: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
decisionHandler(.allow);
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let url = navigationAction.request.url,
url.scheme != "http",
url.scheme != "https"
{
UIApplication.shared.open(url)
decisionHandler(.cancel)
return
}
if let result = self.delegate?.xs_webView?(self, shouldStartLoadWith: navigationAction) {
if result {
decisionHandler(.allow)
} else {
decisionHandler(.cancel)
}
} else {
decisionHandler(.allow)
}
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
self.delegate?.xs_webViewDidStartLoad?(self)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.delegate?.xs_webViewDidFinishLoad?(self)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
self.delegate?.xs_webView?(self, didFailLoadWithError: error)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
self.delegate?.xs_webView?(self, didFailLoadWithError: error)
}
}
//MARK:-------------- WKScriptMessageHandler --------------
extension XSWebView: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
self.delegate?.xs_userContentController?(userContentController, didReceive: message)
}
}

View File

@ -0,0 +1,76 @@
//
// XSDiscoverViewController.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
import JXPlayer
class XSDiscoverViewController: JXPlayerListViewController {
override var contentSize: CGSize {
return .init(width: XSScreen.width, height: XSScreen.height)
}
override var ViewModelClass: JXPlayerListViewModel.Type {
return XSDiscoverViewModel.self
}
var xs_viewModel: XSDiscoverViewModel {
return self.viewModel as! XSDiscoverViewModel
}
deinit {
}
override func viewDidLoad() {
super.viewDidLoad()
self.register(XSDiscoverPlayerCell.self, forCellWithReuseIdentifier: "cell")
self.delegate = self
self.dataSource = self
Task {
await self.xs_viewModel.requestDataArr(page: 1)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: true)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.viewModel.currentCell?.pause()
}
}
//MARK: JXPlayerListViewControllerDataSource
extension XSDiscoverViewController: JXPlayerListViewControllerDataSource {
func jx_playerListViewController(_ viewController: JXPlayerListViewController, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = self.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSDiscoverPlayerCell
cell.model = xs_viewModel.dataArr[indexPath.row]
return cell
}
func jx_playerListViewController(_ viewController: JXPlayerListViewController, numberOfItemsInSection section: Int) -> Int {
return xs_viewModel.dataArr.count
}
}
//MARK: JXPlayerListViewControllerDelegate
extension XSDiscoverViewController: JXPlayerListViewControllerDelegate {
func jx_playerViewControllerLoadMoreData(playerViewController: JXPlayerListViewController) {
Task {
await self.xs_viewModel.requestDataArr(page: self.xs_viewModel.page + 1)
}
}
}

View File

@ -0,0 +1,201 @@
//
// XSDiscoverControlView.swift
// XSeri
//
// Created by 鸿 on 2026/1/12.
//
import UIKit
import JXPlayer
import SnapKit
class XSDiscoverControlView: JXPlayerListControlView {
override var model: Any? {
didSet {
let model = self.model as? XSShortModel
let videoInfo = model?.video_info
epView.currentEp = videoInfo?.episode ?? ""
epView.totalEp = "\(model?.episode_total ?? 0)"
collectButton.setNeedsUpdateConfiguration()
}
}
override var viewModel: JXPlayerListViewModel? {
didSet {
self.viewModel?.addObserver(self, forKeyPath: "isPlaying", context: nil)
}
}
override var durationTime: TimeInterval {
didSet {
updateProgress()
}
}
override var currentTime: TimeInterval {
didSet {
updateProgress()
}
}
override var isCurrent: Bool {
didSet {
updatePlayIconState()
}
}
private lazy var progressView: XSProgressView = {
let view = XSProgressView()
view.insets = .init(top: 10, left: 16, bottom: 10, right: 16)
view.panFinish = { [weak self] progress in
guard let self = self else { return }
self.viewModel?.seekTo(Float(progress))
}
return view
}()
private lazy var epView: XSPlayerEpButton = {
let view = XSPlayerEpButton()
view.addAction(UIAction(handler: { [weak self] _ in
guard let self = self else { return }
let vc = XSShortDetailViewController()
vc.shortId = (self.model as? XSShortModel)?.short_play_id
self.viewController?.navigationController?.pushViewController(vc, animated: true)
}), for: .touchUpInside)
return view
}()
private lazy var collectButton: UIButton = {
var configuration = UIButton.Configuration.plain()
configuration.contentInsets = .zero
configuration.imagePadding = 2
configuration.imagePlacement = .top
configuration.attributedTitle = AttributedString("Save".localized, attributes: AttributeContainer([
.font : UIFont.font(ofSize: 12, weight: .bold),
.foregroundColor : UIColor.FFDAA_4
]))
let button = UIButton(configuration: configuration, primaryAction: UIAction(handler: { [weak self] _ in
guard let self = self else { return }
guard let model = self.model as? XSShortModel else { return }
let isCollect = !(model.is_collect ?? false)
Task {
await XSVideoAPI.requestCollectShort(isCollect: isCollect, shortId: model.short_play_id ?? "0", videoId: model.video_info?.short_play_video_id ?? "0")
}
}))
button.configurationUpdateHandler = { [weak self] button in
guard let self = self else { return }
let model = self.model as? XSShortModel
let videoInfo = model?.video_info
if model?.is_collect == true {
button.configuration?.image = UIImage(named: "collect_icon_01_selected")
} else {
button.configuration?.image = UIImage(named: "collect_icon_01")
}
}
return button
}()
private lazy var playIconImageView = UIImageView()
deinit {
NotificationCenter.default.removeObserver(self)
}
override init(frame: CGRect) {
super.init(frame: frame)
NotificationCenter.default.addObserver(self, selector: #selector(updateShortCollectStateNotification), name: XSVideoAPI.updateShortCollectStateNotification, object: nil)
xs_setupUI()
}
@MainActor required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "isPlaying" {
self.updatePlayIconState()
}
}
private func updateProgress() {
if durationTime == 0 || currentTime == 0 {
progressView.progress = 0
return
}
progressView.progress = currentTime / durationTime
}
override func singleTapEvent() {
super.singleTapEvent()
self.viewModel?.userSwitchPlayAndPause()
}
private func updatePlayIconState() {
let model = self.model as? XSShortModel
let videoInfo = model?.video_info
if videoInfo?.is_lock == true {
playIconImageView.isHidden = true
} else {
playIconImageView.isHidden = false
if isCurrent == true, self.viewModel?.isPlaying != true {
playIconImageView.image = UIImage(named: "play_icon_01")
} else {
playIconImageView.image = UIImage(named: "pause_icon_01")
}
}
}
@objc private func updateShortCollectStateNotification(sender: Notification) {
let model = self.model as? XSShortModel
guard let userInfo = sender.userInfo else { return }
guard let shortPlayId = userInfo["id"] as? String else { return }
guard let state = userInfo["state"] as? Bool else { return }
guard shortPlayId == model?.short_play_id else { return }
model?.is_collect = state
self.collectButton.setNeedsUpdateConfiguration()
}
}
extension XSDiscoverControlView {
private func xs_setupUI() {
addSubview(progressView)
addSubview(epView)
addSubview(collectButton)
addSubview(playIconImageView)
progressView.snp.makeConstraints { make in
make.left.equalToSuperview()
make.right.equalToSuperview()
make.bottom.equalToSuperview().offset(-(XSScreen.customTabBarHeight))
}
epView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.centerX.equalToSuperview()
make.bottom.equalTo(progressView.snp.top).offset(-2)
}
collectButton.snp.makeConstraints { make in
make.bottom.equalTo(epView.snp.top).offset(-18)
make.right.equalToSuperview().offset(-16)
}
playIconImageView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview()
}
}
}

View File

@ -0,0 +1,28 @@
//
// XSDiscoverPlayerCell.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
import JXPlayer
class XSDiscoverPlayerCell: JXPlayerListCell {
override var ControlViewClass: JXPlayerListControlView.Type {
return XSDiscoverControlView.self
}
override var model: Any? {
didSet {
let model = self.model as? XSShortModel
let videoInfo = model?.video_info
self.player.setPlayUrl(url: videoInfo?.video_url ?? "")
self.player.coverImageView?.xs_setImage(model?.image_url)
}
}
}

View File

@ -0,0 +1,54 @@
//
// XSDiscoverViewModel.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
import JXPlayer
class XSDiscoverViewModel: JXPlayerListViewModel {
private(set) var dataArr: [XSShortModel] = []
private(set) var page = 1
@MainActor
private func addDataArr(dataArr: [XSShortModel]) {
guard dataArr.count > 0 else { return }
var indexPaths: [IndexPath] = []
var startRow = self.dataArr.count
dataArr.forEach { _ in
indexPaths.append(IndexPath(row: startRow, section: 0))
startRow += 1
}
self.dataArr += dataArr
CATransaction.setCompletionBlock(nil)
CATransaction.begin()
self.playerListVC?.collectionView.insertItems(at: indexPaths)
CATransaction.commit()
}
}
extension XSDiscoverViewModel {
@MainActor
func requestDataArr(page: Int, completer: (() -> Void)? = nil) async {
guard let dataArr = await XSHomeAPI.requestDiscoverData(page: page) else { return }
self.page = page
if page == 1 {
self.playerListVC?.clearData()
self.dataArr = dataArr
self.playerListVC?.reloadData { [weak self] in
self?.playerListVC?.scrollToItem(indexPath: .init(row: 0, section: 0), animated: false)
}
} else {
self.addDataArr(dataArr: dataArr)
}
}
}

View File

@ -0,0 +1,133 @@
//
// XSHomeCategoriesViewController.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
import SnapKit
class XSHomeCategoriesViewController: XSHomeChildViewController {
private lazy var categoryArr: [XSCategoryModel] = []
private lazy var categoryIndex = 0
private lazy var dataArr: [XSShortModel] = []
private lazy var page = 1
private lazy var layout: UICollectionViewFlowLayout = {
let width = (XSScreen.width - 32 - 20) / 3
let coverHeight = 142 / 107 * width
let height = coverHeight + 40
let layout = UICollectionViewFlowLayout()
layout.itemSize = .init(width: floor(width), height: height)
layout.minimumLineSpacing = 10
layout.minimumInteritemSpacing = 10
layout.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16)
layout.headerReferenceSize = .init(width: XSScreen.width, height: 1)
return layout
}()
private lazy var collectionView: XSCollectionView = {
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.showsHorizontalScrollIndicator = false
collectionView.contentInset = .init(top: 0, left: 0, bottom: XSScreen.customTabBarHeight + 10, right: 0)
collectionView.register(XSHomeCategoriesCell.self, forCellWithReuseIdentifier: "cell")
collectionView.register(XSHomeCategoriesHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "header")
return collectionView
}()
override func viewDidLoad() {
super.viewDidLoad()
xs_setupUI()
Task {
await requestCategories()
await requestCategoryData(page: 1)
}
}
}
extension XSHomeCategoriesViewController {
private func xs_setupUI() {
view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(self.topHeight + 15)
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension XSHomeCategoriesViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSHomeCategoriesCell
cell.model = self.dataArr[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.dataArr.count
}
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "header", for: indexPath) as! XSHomeCategoriesHeaderView
view.didChangeHeight = { [weak self] height in
guard let self = self else { return }
self.layout.headerReferenceSize = .init(width: XSScreen.width, height: height)
}
view.didSelected = { [weak self] index in
guard let self = self else { return }
self.categoryIndex = index
Task {
await self.requestCategoryData(page: 1)
}
}
view.dataArr = self.categoryArr
return view
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let model = self.dataArr[indexPath.row]
let vc = XSShortDetailViewController()
vc.shortId = model.short_play_id
self.navigationController?.pushViewController(vc, animated: true)
}
}
extension XSHomeCategoriesViewController {
private func requestCategories() async {
guard let dataArr = await XSHomeAPI.requestCategoryList() else { return }
self.categoryArr = dataArr
self.layout.headerReferenceSize = .init(width: XSScreen.width, height: 1)
self.collectionView.reloadData()
}
private func requestCategoryData(page: Int) async {
guard self.categoryIndex < self.categoryArr.count else { return }
guard let id = self.categoryArr[categoryIndex].id else { return }
guard let dataArr = await XSHomeAPI.requestCategoryVideo(id: id, page: page) else { return }
if page == 1 {
self.dataArr.removeAll()
}
self.dataArr += dataArr
self.page = page
self.collectionView.reloadData()
}
}

View File

@ -0,0 +1,24 @@
//
// XSHomeChildViewController.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
import SnapKit
class XSHomeChildViewController: XSViewController {
var topHeight: CGFloat {
return XSScreen.safeTop + 95
}
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = .clear
}
}

View File

@ -0,0 +1,180 @@
//
// XSHomeNewViewController.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
import SnapKit
class XSHomeNewViewController: XSHomeChildViewController {
private lazy var dataArr: [XSShortModel] = []
private lazy var page: Int = 1
private lazy var layout: UICollectionViewCompositionalLayout = {
let layout = UICollectionViewCompositionalLayout { section, environment in
if section == 0 {
let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(32)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(210), heightDimension: .absolute(360)), subitems: [item])
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = 15
layoutSection.orthogonalScrollingBehavior = .continuous
layoutSection.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
layoutSection.boundarySupplementaryItems = [headerItem]
return layoutSection
} else {
let headerItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(57)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(146)), subitems: [item])
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = 12
layoutSection.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
layoutSection.boundarySupplementaryItems = [headerItem]
return layoutSection
}
}
return layout
}()
private lazy var collectionView: XSCollectionView = {
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.contentInset = .init(top: 0, left: 0, bottom: XSScreen.customTabBarHeight + 10, right: 0)
collectionView.xs_addRefreshHeader { [weak self] in
self?.handleHeaderRefresh(nil)
}
collectionView.xs_addRefreshFooter(insetBottom: 0) { [weak self] in
self?.handleFooterRefresh(nil)
}
collectionView.register(XSHomeNewBigCell.self, forCellWithReuseIdentifier: "XSHomeNewBigCell")
collectionView.register(XSHomeNewCell.self, forCellWithReuseIdentifier: "XSHomeNewCell")
collectionView.register(XSHomeNewTitleView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "title")
return collectionView
}()
override func viewDidLoad() {
super.viewDidLoad()
xs_setupUI()
Task {
await requestDataArr(page: 1)
}
}
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
Task {
await requestDataArr(page: 1)
self.collectionView.xs_endHeaderRefreshing()
}
}
override func handleFooterRefresh(_ completer: (() -> Void)?) {
Task {
await requestDataArr(page: self.page + 1)
self.collectionView.xs_endFooterRefreshing()
}
}
}
extension XSHomeNewViewController {
private func xs_setupUI() {
view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(self.topHeight + 13)
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension XSHomeNewViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
var model: XSShortModel
if indexPath.section == 0 {
model = self.dataArr[indexPath.row]
} else {
model = self.dataArr[indexPath.row + 3]
}
if indexPath.section == 0 {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSHomeNewBigCell", for: indexPath) as! XSHomeNewBigCell
cell.model = model
return cell
} else {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSHomeNewCell", for: indexPath) as! XSHomeNewCell
cell.model = model
return cell
}
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
let totalCount = self.dataArr.count
if section == 0 {
return min(3, totalCount)
} else {
return totalCount - 3
}
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
if self.dataArr.count > 3 {
return 2
} else if self.dataArr.count > 0 {
return 1
} else {
return 0
}
}
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "title", for: indexPath) as! XSHomeNewTitleView
view.titleLabel.text = "New Releases".localized
return view
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
var model: XSShortModel
if indexPath.section == 0 {
model = self.dataArr[indexPath.row]
} else {
model = self.dataArr[indexPath.row + 3]
}
let vc = XSShortDetailViewController()
vc.shortId = model.short_play_id
self.navigationController?.pushViewController(vc, animated: true)
}
}
extension XSHomeNewViewController {
private func requestDataArr(page: Int) async {
guard let list = await XSHomeAPI.requestHomeNew(page: page) else { return }
if page == 1 {
self.dataArr.removeAll()
}
self.page = page
self.dataArr += list
self.collectionView.reloadData()
}
}

View File

@ -0,0 +1,165 @@
//
// XSHomePopularViewController.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import SnapKit
class XSHomePopularViewController: XSHomeChildViewController {
private lazy var viewModel = XSHomeViewModel()
private lazy var layout: XSWaterfallFlowLayout = {
let layout = XSWaterfallFlowLayout()
layout.delegate = self
return layout
}()
private lazy var collectionView: XSCollectionView = {
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: self.layout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.contentInset = .init(top: 0, left: 0, bottom: XSScreen.customTabBarHeight + 10, right: 0)
collectionView.xs_addRefreshHeader { [weak self] in
self?.handleHeaderRefresh(nil)
}
collectionView.register(XSHomePopularCell.self, forCellWithReuseIdentifier: "XSHomePopularCell")
collectionView.register(XSHomePopularBigCell.self, forCellWithReuseIdentifier: "XSHomePopularBigCell")
return collectionView
}()
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(networkStatusDidChangeNotification), name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
xs_setupUI()
Task {
await self.requestDataArr()
}
}
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
Task {
await self.requestDataArr()
self.collectionView.xs_endHeaderRefreshing()
}
}
@objc private func networkStatusDidChangeNotification() {
guard XSNetworkMonitorManager.manager.isReachable == true else { return }
guard self.viewModel.dataArr.isEmpty else { return }
Task {
await self.requestDataArr()
}
}
}
extension XSHomePopularViewController {
private func xs_setupUI() {
view.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(self.topHeight + 15)
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension XSHomePopularViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let itemModel = self.viewModel.dataArr[indexPath.section]
let model = itemModel.list?[indexPath.row]
if itemModel.module_key == .week_highest_recommend {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSHomePopularCell", for: indexPath) as! XSHomePopularCell
cell.model = model
return cell
} else {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSHomePopularBigCell", for: indexPath) as! XSHomePopularBigCell
cell.model = model
return cell
}
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.viewModel.dataArr[section].list?.count ?? 0
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return self.viewModel.dataArr.count
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let itemModel = self.viewModel.dataArr[indexPath.section]
let model = itemModel.list?[indexPath.row]
let vc = XSShortDetailViewController()
vc.shortId = model?.short_play_id
self.navigationController?.pushViewController(vc, animated: true)
}
}
//MARK: XSWaterfallMutiSectionDelegate
extension XSHomePopularViewController: XSWaterfallMutiSectionDelegate {
func heightForRowAtIndexPath(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, indexPath: IndexPath, itemWidth: CGFloat) -> CGFloat {
if indexPath.section == 0 {
return 146 + 57
} else {
return 219 + 92
}
}
func columnNumber(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> Int {
if section == 0 {
return 3
} else {
return 2
}
}
func insetForSection(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> UIEdgeInsets {
return .init(top: 0, left: 16, bottom: 0, right: 16)
}
func interitemSpacing(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> CGFloat {
return 13
}
func lineSpacing(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> CGFloat {
return 16
}
func spacingWithLastSection(collectionView collection: UICollectionView, layout: XSWaterfallFlowLayout, section: Int) -> CGFloat {
if section > 0 {
return 26
} else {
return 0
}
}
}
extension XSHomePopularViewController {
private func requestDataArr() async {
await self.viewModel.requestHomeData()
self.collectionView.reloadData()
}
}

View File

@ -0,0 +1,111 @@
//
// XSHomeRankingsViewController.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
import SnapKit
class XSHomeRankingsViewController: XSHomeChildViewController {
private lazy var dataArr: [XSShortModel] = []
private lazy var bgView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "rankings_bg_image"))
return imageView
}()
private lazy var layout: UICollectionViewLayout = {
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(84)), subitems: [item])
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = 10
layoutSection.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
let layout = UICollectionViewCompositionalLayout(section: layoutSection)
return layout
}()
private lazy var collectionView: XSCollectionView = {
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.contentInset = .init(top: 0, left: 0, bottom: XSScreen.customTabBarHeight + 10, right: 0)
collectionView.xs_addRefreshHeader { [weak self] in
self?.handleHeaderRefresh(nil)
}
collectionView.register(XSHomeRankingsCell.self, forCellWithReuseIdentifier: "cell")
return collectionView
}()
override func viewDidLoad() {
super.viewDidLoad()
xs_setupUI()
Task {
await requestDataArr()
}
}
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
Task {
await requestDataArr()
self.collectionView.xs_endHeaderRefreshing()
}
}
}
extension XSHomeRankingsViewController {
private func xs_setupUI() {
view.addSubview(bgView)
view.addSubview(collectionView)
bgView.snp.makeConstraints { make in
make.left.right.top.equalToSuperview()
}
collectionView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview().offset(self.topHeight + 15)
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension XSHomeRankingsViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSHomeRankingsCell
cell.num = indexPath.row + 1
cell.model = self.dataArr[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.dataArr.count
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let model = self.dataArr[indexPath.row]
let vc = XSShortDetailViewController()
vc.shortId = model.short_play_id
self.navigationController?.pushViewController(vc, animated: true)
}
}
extension XSHomeRankingsViewController {
private func requestDataArr() async {
guard let list = await XSHomeAPI.requestHomeRankings() else { return }
self.dataArr = list
self.collectionView.reloadData()
}
}

View File

@ -0,0 +1,131 @@
//
// XSHomeViewController.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import SnapKit
import JXSegmentedView
class XSHomeViewController: XSViewController {
private let titles: [String] = [
"Popular".localized,
"New".localized,
"Rankings".localized,
"Categories".localized,
]
private let viewControllers: [XSViewController] = {
let vc1 = XSHomePopularViewController()
let vc2 = XSHomeNewViewController()
let vc3 = XSHomeRankingsViewController()
let vc4 = XSHomeCategoriesViewController()
return [vc1, vc2, vc3, vc4]
}()
// MARK: - UI Components
private lazy var searchButton: XSHomeSearchButton = {
let button = XSHomeSearchButton()
button.addAction(UIAction(handler: { [weak self] _ in
guard let self = self else { return }
let vc = XSSearchViewController()
self.navigationController?.pushViewController(vc, animated: true)
}), for: .touchUpInside)
return button
}()
/// Segmented
private lazy var segmentedDataSource: JXSegmentedTitleDataSource = {
let dataSource = JXSegmentedTitleDataSource()
dataSource.titles = self.titles
dataSource.titleNormalColor = UIColor.white.withAlphaComponent(0.55)
dataSource.titleSelectedColor = .white
dataSource.titleNormalFont = .font(ofSize: 16, weight: .medium)
dataSource.titleSelectedFont = .font(ofSize: 16, weight: .semibold)
dataSource.isTitleColorGradientEnabled = true
dataSource.itemSpacing = 24
return dataSource
}()
/// Segmented (Tab )
private lazy var segmentedView: JXSegmentedView = {
let view = JXSegmentedView()
view.dataSource = segmentedDataSource
view.delegate = self
view.listContainer = listContainerView
view.contentEdgeInsetLeft = 16
view.contentEdgeInsetRight = 16
view.backgroundColor = .clear
return view
}()
/// ()
private lazy var listContainerView: JXSegmentedListContainerView = {
return JXSegmentedListContainerView(dataSource: self)
}()
// MARK: - Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
xs_setupUI()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: true)
}
}
// MARK: - UI Setup
extension XSHomeViewController {
private func xs_setupUI() {
view.addSubview(listContainerView)
view.addSubview(searchButton)
view.addSubview(segmentedView)
searchButton.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.centerX.equalToSuperview()
make.top.equalToSuperview().offset(XSScreen.safeTop + 15)
make.height.equalTo(32)
}
segmentedView.snp.makeConstraints { make in
make.left.equalToSuperview()
make.right.equalToSuperview()
make.top.equalTo(searchButton.snp.bottom).offset(10)
make.height.equalTo(30)
}
listContainerView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalToSuperview()
// make.top.equalTo(segmentedView.snp.bottom).offset(8)
}
}
}
// MARK: - JXSegmentedViewDelegate
extension XSHomeViewController: JXSegmentedViewDelegate {
func segmentedView(_ segmentedView: JXSegmentedView, didSelectedItemAt index: Int) {
// Tab
}
}
// MARK: - JXSegmentedListContainerViewDataSource
extension XSHomeViewController: JXSegmentedListContainerViewDataSource {
func numberOfLists(in listContainerView: JXSegmentedListContainerView) -> Int {
return segmentedDataSource.titles.count
}
func listContainerView(_ listContainerView: JXSegmentedListContainerView, initListAt index: Int) -> JXSegmentedListContainerViewListDelegate {
return viewControllers[index]
}
}

View File

@ -0,0 +1,390 @@
//
// XSSearchViewController.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
import LYEmptyView
final class XSSearchViewController: XSViewController {
private enum Mode {
case idle
case suggest
case result
}
private let headerView = XSSearchHeaderView()
private let historyHotView = XSSearchHistoryHotView()
private lazy var suggestionCollectionView: XSCollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = 12
layout.minimumInteritemSpacing = 0
layout.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16)
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.isScrollEnabled = true
collectionView.keyboardDismissMode = .onDrag
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(XSSearchSuggestionCell.self, forCellWithReuseIdentifier: "XSSearchSuggestionCell")
return collectionView
}()
private lazy var resultCollectionView: XSCollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = 16
layout.minimumInteritemSpacing = 8
layout.sectionInset = .init(top: 0, left: 16, bottom: 0, right: 16)
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.isScrollEnabled = true
collectionView.keyboardDismissMode = .onDrag
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(XSSearchResultCell.self, forCellWithReuseIdentifier: "XSSearchResultCell")
return collectionView
}()
private var history: [String] = []
private var suggestionItems: [XSShortModel] = []
private var resultItems: [XSShortModel] = []
private var currentSuggestKeyword = ""
private var currentResultKeyword = ""
private var mode: Mode = .idle {
didSet {
xs_updateVisibility()
}
}
private var suggestionTask: Task<Void, Never>?
private var resultTask: Task<Void, Never>?
private var hotTask: Task<Void, Never>?
deinit {
suggestionTask?.cancel()
resultTask?.cancel()
hotTask?.cancel()
}
override func viewDidLoad() {
super.viewDidLoad()
xs_setupUI()
xs_bindData()
xs_loadHistory()
xs_loadHotSearches()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: true)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let _ = self.headerView.becomeFirstResponder()
}
}
extension XSSearchViewController {
private func xs_setupUI() {
view.addSubview(headerView)
view.addSubview(historyHotView)
view.addSubview(suggestionCollectionView)
view.addSubview(resultCollectionView)
headerView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(XSScreen.safeTop + 12)
make.left.equalToSuperview().offset(16)
make.right.equalToSuperview().offset(-16)
make.height.equalTo(38)
}
historyHotView.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom).offset(22)
make.left.right.equalToSuperview()
}
suggestionCollectionView.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom).offset(22)
make.left.right.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
resultCollectionView.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom).offset(22)
make.left.right.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide)
}
//
resultCollectionView.ly_emptyView = XSEmpty.xs_emptyView()
//
suggestionCollectionView.ly_emptyView = XSEmpty.xs_emptyView()
}
private func xs_bindData() {
headerView.placeholder = XSSearchData.searchPlaceholder
headerView.didTapBack = { [weak self] in
self?.navigationController?.popViewController(animated: true)
}
headerView.didBeginEditing = { [weak self] text in
guard let self = self else { return }
//
if self.mode == .result { return }
self.xs_handleSuggest(text)
}
headerView.didChangeText = { [weak self] text in
self?.xs_handleSuggest(text)
}
headerView.didTapSearch = { [weak self] text in
self?.xs_submitSearch(text)
}
historyHotView.historyTitle = XSSearchData.recentTitle
historyHotView.didTapClear = { [weak self] in
self?.xs_clearHistory()
}
historyHotView.didSelectHistory = { [weak self] text in
self?.headerView.text = text
self?.xs_submitSearch(text)
}
historyHotView.didSelectHot = { [weak self] model in
self?.xs_pushDetail(model: model)
}
historyHotView.hotTitle = XSSearchData.hotSearchesTitle
xs_updateVisibility()
}
}
// MARK: - Data
extension XSSearchViewController {
private func xs_loadHotSearches() {
hotTask?.cancel()
hotTask = Task { [weak self] in
//
let hotList = await XSHomeAPI.requestHotSearchList() ?? []
let topList = await XSHomeAPI.requestTopSearchList() ?? []
guard !Task.isCancelled else { return }
await MainActor.run {
self?.xs_updateHotSection(hotList: Array(hotList.prefix(5)), topList: Array(topList.prefix(5)))
}
}
}
private func xs_updateHotSection(hotList: [XSShortModel], topList: [XSShortModel]) {
var arr: [XSSearchHotSection] = []
if !hotList.isEmpty {
arr.append(XSSearchHotSection(icon: UIImage(named: "hot_icon_03"), title: XSSearchData.hotSectionTitles[0], style: .gold, items: hotList))
}
if !topList.isEmpty {
arr.append(XSSearchHotSection(icon: UIImage(named: "hot_icon_04"), title: XSSearchData.hotSectionTitles[1], style: .purple, items: topList))
}
historyHotView.hotSections = arr
xs_updateVisibility()
}
private func xs_fetchSuggestions(_ keyword: String) {
suggestionTask?.cancel()
let expectedKeyword = keyword
suggestionTask = Task { [weak self] in
//
let list = await XSHomeAPI.requestSearch(text: keyword) ?? []
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self = self,
self.mode == .suggest,
self.currentSuggestKeyword == expectedKeyword else {
return
}
self.suggestionItems = list
self.suggestionCollectionView.reloadData()
}
}
}
private func xs_fetchResults(_ keyword: String) {
resultTask?.cancel()
let expectedKeyword = keyword
resultTask = Task { [weak self] in
//
let list = await XSHomeAPI.requestSearch(text: keyword) ?? []
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self = self,
self.mode == .result,
self.currentResultKeyword == expectedKeyword else {
return
}
self.resultItems = list
self.resultCollectionView.reloadData()
}
}
}
}
// MARK: - Actions
extension XSSearchViewController {
private func xs_handleSuggest(_ text: String?) {
let keyword = (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if keyword.isEmpty {
suggestionTask?.cancel()
resultTask?.cancel()
currentSuggestKeyword = ""
mode = .idle
suggestionItems = []
suggestionCollectionView.reloadData()
return
}
resultTask?.cancel()
resultItems = []
resultCollectionView.reloadData()
resultCollectionView.isHidden = true
currentSuggestKeyword = keyword
mode = .suggest
suggestionCollectionView.isHidden = false
xs_fetchSuggestions(keyword)
}
private func xs_submitSearch(_ text: String?) {
let keyword = (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyword.isEmpty else {
mode = .idle
return
}
suggestionTask?.cancel()
suggestionItems = []
suggestionCollectionView.reloadData()
suggestionCollectionView.isHidden = true
currentResultKeyword = keyword
view.endEditing(true)
mode = .result
xs_addHistory(keyword)
xs_fetchResults(keyword)
}
}
// MARK: - History
extension XSSearchViewController {
private func xs_loadHistory() {
history = UserDefaults.standard.stringArray(forKey: XSSearchData.historyKey) ?? []
historyHotView.historyTags = history
xs_updateVisibility()
}
private func xs_addHistory(_ keyword: String) {
// 10
history.removeAll { $0.caseInsensitiveCompare(keyword) == .orderedSame }
history.insert(keyword, at: 0)
if history.count > 10 {
history = Array(history.prefix(10))
}
UserDefaults.standard.set(history, forKey: XSSearchData.historyKey)
historyHotView.historyTags = history
xs_updateVisibility()
}
private func xs_clearHistory() {
history = []
UserDefaults.standard.removeObject(forKey: XSSearchData.historyKey)
historyHotView.historyTags = []
xs_updateVisibility()
}
}
// MARK: - Layout
extension XSSearchViewController {
private func xs_updateVisibility() {
switch mode {
case .idle:
historyHotView.isHidden = !historyHotView.hasContent
suggestionCollectionView.isHidden = true
resultCollectionView.isHidden = true
headerView.style = .home
case .suggest:
historyHotView.isHidden = true
suggestionCollectionView.isHidden = false
resultCollectionView.isHidden = true
headerView.style = .input
case .result:
historyHotView.isHidden = true
suggestionCollectionView.isHidden = true
resultCollectionView.isHidden = false
headerView.style = .input
}
}
}
// MARK: - UICollectionViewDelegate UICollectionViewDataSource
extension XSSearchViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
if collectionView == suggestionCollectionView {
return suggestionItems.count
}
return resultItems.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if collectionView == suggestionCollectionView {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSSearchSuggestionCell", for: indexPath) as! XSSearchSuggestionCell
let item = suggestionItems[indexPath.row]
let keyword = headerView.text ?? ""
cell.configure(model: item, keyword: keyword)
return cell
} else {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "XSSearchResultCell", for: indexPath) as! XSSearchResultCell
cell.configure(model: resultItems[indexPath.row])
return cell
}
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if collectionView == suggestionCollectionView {
let item = suggestionItems[indexPath.row]
xs_pushDetail(model: item)
} else if collectionView == resultCollectionView {
let item = resultItems[indexPath.row]
xs_pushDetail(model: item)
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
if collectionView == suggestionCollectionView {
let width = XSScreen.width - 32
return CGSize(width: floor(width), height: 104)
} else {
let width = (XSScreen.width - 32 - 16) / 3
return CGSize(width: floor(width), height: 186)
}
}
}
// MARK: - Detail
extension XSSearchViewController {
private func xs_pushDetail(model: XSShortModel) {
let shortId = model.short_play_id ?? model.short_play_video_id ?? model.id
guard let shortId = shortId, !shortId.isEmpty else { return }
let controller = XSShortDetailViewController()
controller.shortId = shortId
navigationController?.pushViewController(controller, animated: true)
}
}

View File

@ -0,0 +1,22 @@
//
// XSCategoryModel.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
import SmartCodable
struct XSCategoryModel: SmartCodable {
var id: String?
var name: String?
static func mappingForKey() -> [SmartKeyTransformer]? {
return [
CodingKeys.id <--- ["id", "category_id"],
CodingKeys.name <--- ["name", "category_name"]
]
}
}

View File

@ -0,0 +1,16 @@
import Foundation
struct XSHomeData {
/// Tab
static let topTabs = [
"Popular",
"New",
"Rankings",
"Categories",
"aaaaa",
"gfgfggg",
"ffffff"
]
}

View File

@ -0,0 +1,66 @@
//
// XSHomeModuleItem.swift
// XSeri
//
// Created by 鸿 on 2026/1/12.
//
import UIKit
import SmartCodable
class XSHomeModuleItem: NSObject, SmartCodable {
enum ModuleKey: String, SmartCaseDefaultable {
case banner = "home_banner"
case v3_recommand = "home_v3_recommand"
///
case cagetory_recommand = "home_cagetory_recommand"
case week_ranking = "week_ranking"
///
case marquee = "marquee"
case new_recommand = "new_recommand"
case week_highest_recommend = "week_highest_recommend"
}
required override init() { }
var module_key: ModuleKey?
var title: String?
var list: [XSShortModel]?
@SmartAny
var data: Any?
@IgnoredKey
var iconImage: UIImage?
@IgnoredKey
var br_cellHeight: CGFloat?
func didFinishMapping() {
if let data = data as? [[String : Any]] {
self.list = [XSShortModel].deserialize(from: data)
} else if let data = data as? [String : Any] {
var dataList: [[String : Any]]?
if let list = data["list"] as? [[String : Any]] {
self.title = data["title"] as? String
dataList = list
} else if let list = data["shortPlayList"] as? [[String : Any]] {
self.title = data["category_name"] as? String
dataList = list
}
if let dataList = dataList {
self.list = [XSShortModel].deserialize(from: dataList)
}
}
}
}

View File

@ -0,0 +1,41 @@
//
// XSSearchData.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import Foundation
import UIKit
///
struct XSSearchData {
///
static let searchPlaceholder = "Love in Ashes is he".localized
///
static let recentTitle = "Recent".localized
///
static let hotSearchesTitle = "Hot Searches".localized
///
static let hotSectionTitles = [
"Rising Popularity".localized,
"Top-Rated Charts".localized
]
///
static let historyKey = "xs_search_history"
}
///
struct XSSearchHotSection {
let icon: UIImage?
let title: String
let style: XSSearchHotSectionStyle
let items: [XSShortModel]
}
///
enum XSSearchHotSectionStyle {
case gold
case purple
}

View File

@ -0,0 +1,69 @@
//
// XSHomeCategoriesCell.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
import SnapKit
class XSHomeCategoriesCell: UICollectionViewCell {
var model: XSShortModel? {
didSet {
coverImageView.xs_setImage(model?.image_url)
titleLabel.text = model?.name
}
}
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.layer.cornerRadius = 10
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 10, weight: .semibold)
label.textColor = .white
label.numberOfLines = 2
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.layer.cornerRadius = 10
contentView.layer.masksToBounds = true
contentView.layer.borderWidth = 1
contentView.layer.borderColor = UIColor._6_D_71_E_0.withAlphaComponent(0.4).cgColor
xs_layoutUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSHomeCategoriesCell {
private func xs_layoutUI() {
contentView.addSubview(coverImageView)
contentView.addSubview(titleLabel)
coverImageView.snp.makeConstraints { make in
make.left.right.top.equalToSuperview()
make.bottom.equalToSuperview().offset(-40)
}
titleLabel.snp.makeConstraints { make in
make.left.equalToSuperview().offset(5)
make.right.lessThanOrEqualToSuperview().offset(-5)
make.top.equalTo(coverImageView.snp.bottom).offset(6)
}
}
}

View File

@ -0,0 +1,121 @@
//
// XSHomeCategoriesHeaderView.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
import collection_view_layouts
import SnapKit
class XSHomeCategoriesHeaderView: UICollectionReusableView {
var didChangeHeight: ((_ height: CGFloat) -> Void)?
var didSelected: ((_ index: Int) -> Void)?
var dataArr: [XSCategoryModel] = [] {
didSet {
self.collectionView.reloadData()
}
}
var currentIndex: Int = 0 {
didSet {
self.collectionView.reloadData()
}
}
private lazy var layout: TagsLayout = {
let layout = TagsLayout()
layout.delegate = self
layout.cellsPadding = .init(horizontal: 12, vertical: 16)
layout.contentPadding = .init(horizontal: 16, vertical: 0)
return layout
}()
private lazy var collectionView: XSCollectionView = {
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.isScrollEnabled = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.addObserver(self, forKeyPath: "contentSize", context: nil)
collectionView.register(XSHomeCategoriesTagsCell.self, forCellWithReuseIdentifier: "cell")
return collectionView
}()
deinit {
self.collectionView.removeObserver(self, forKeyPath: "contentSize")
}
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentSize" {
self.didChangeHeight?(self.collectionView.contentSize.height + 14)
}
}
}
extension XSHomeCategoriesHeaderView {
private func xs_setupUI() {
addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.left.right.equalToSuperview()
make.top.equalToSuperview()
make.bottom.equalToSuperview().offset(-14)
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension XSHomeCategoriesHeaderView: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSHomeCategoriesTagsCell
cell.xs_isSelected = currentIndex == indexPath.row
cell.model = self.dataArr[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.dataArr.count
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard currentIndex != indexPath.row else { return }
self.currentIndex = indexPath.row
self.collectionView.reloadData()
self.didSelected?(indexPath.row)
}
}
//MARK: LayoutDelegate
extension XSHomeCategoriesHeaderView: LayoutDelegate {
func cellSize(indexPath: IndexPath) -> CGSize {
let model = self.dataArr[indexPath.row]
let text = model.name ?? ""
let width = text.size(XSHomeCategoriesTagsCell.textFont, .init(width: XSScreen.width, height: 27)).width
return .init(width: width + 24, height: 27)
}
}

View File

@ -0,0 +1,81 @@
//
// XSHomeCategoriesTagsCell.swift
// XSeri
//
// Created by 鸿 on 2026/1/4.
//
import UIKit
import SnapKit
class XSHomeCategoriesTagsCell: UICollectionViewCell {
static let textFont = UIFont.font(ofSize: 12, weight: .regular)
static let selectedTextFont = UIFont.font(ofSize: 12, weight: .bold)
static let textColor = UIColor.white
static let selectedTextColor = UIColor._282828
var model: XSCategoryModel? {
didSet {
titleLabel.text = model?.name
}
}
var xs_isSelected: Bool = false {
didSet {
if xs_isSelected {
titleLabel.font = Self.selectedTextFont
titleLabel.textColor = Self.selectedTextColor
bgView.isHidden = false
} else {
titleLabel.font = Self.textFont
titleLabel.textColor = Self.textColor
bgView.isHidden = true
}
}
}
private lazy var bgView: UIImageView = {
let view = UIImageView(image: UIImage(named: "gradient_color_image_01"))
return view
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.contentView.backgroundColor = .FFEFD_8.withAlphaComponent(0.15)
self.contentView.layer.masksToBounds = true
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
self.contentView.layer.cornerRadius = self.contentView.bounds.height / 2
}
}
extension XSHomeCategoriesTagsCell {
private func xs_setupUI() {
contentView.addSubview(bgView)
contentView.addSubview(titleLabel)
bgView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
}

View File

@ -0,0 +1,103 @@
//
// XSHomeNewBigCell.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
import SnapKit
class XSHomeNewBigCell: UICollectionViewCell {
var model: XSShortModel? {
didSet {
coverBigImageView.xs_setImage(model?.image_url)
coverImageView.xs_setImage(model?.image_url)
titleLabel.text = model?.name
categoryLabel.text = model?.category?.first
}
}
private lazy var coverBigImageView: XSImageView = {
let imageView = XSImageView()
return imageView
}()
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.layer.cornerRadius = 4
imageView.layer.borderWidth = 1
imageView.layer.borderColor = UIColor.white.withAlphaComponent(0.25).cgColor
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .medium)
label.textColor = .white
label.numberOfLines = 2
return label
}()
private lazy var categoryLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .regular)
label.textColor = .white.withAlphaComponent(0.5)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.titleLabel.text = "What Goes Around Comes Around"
self.categoryLabel.text = "Antiquity"
contentView.backgroundColor = .white.withAlphaComponent(0.06)
contentView.layer.cornerRadius = 8
contentView.layer.masksToBounds = true
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSHomeNewBigCell {
private func xs_setupUI() {
contentView.addSubview(coverBigImageView)
contentView.addSubview(coverImageView)
contentView.addSubview(titleLabel)
contentView.addSubview(categoryLabel)
coverBigImageView.snp.makeConstraints { make in
make.left.right.top.equalToSuperview()
make.height.equalTo(280)
}
coverImageView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(9)
make.top.equalTo(coverBigImageView.snp.bottom).offset(9)
make.width.equalTo(45)
make.height.equalTo(60)
}
titleLabel.snp.makeConstraints { make in
make.left.equalTo(coverImageView.snp.right).offset(7)
make.right.lessThanOrEqualToSuperview().offset(-10)
make.top.equalTo(coverBigImageView.snp.bottom).offset(9)
}
categoryLabel.snp.makeConstraints { make in
make.left.equalTo(titleLabel)
make.right.lessThanOrEqualToSuperview().offset(-10)
make.top.equalTo(titleLabel.snp.bottom).offset(3)
}
}
}

View File

@ -0,0 +1,154 @@
//
// XSHomeNewCell.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
import SnapKit
class XSHomeNewCell: UICollectionViewCell {
var model: XSShortModel? {
didSet {
coverImageView.xs_setImage(model?.image_url)
titleLabel.text = model?.name
desLabel.text = model?.xs_description
epLabel.text = "## Episodes".localizedReplace(text: "\(model?.episode_total ?? 0)")
if let category = model?.category?.first, !category.isEmpty {
categoryView.isHidden = false
categoryLabel.text = category
} else {
categoryView.isHidden = true
}
if model?.tag_type == .hot {
tagView.isHidden = false
tagView.image = UIImage(named: "hot_icon_02")
} else if model?.tag_type == .new {
tagView.isHidden = false
tagView.image = UIImage(named: "new_icon_01")
} else {
tagView.isHidden = true
}
}
}
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.layer.cornerRadius = 4
return imageView
}()
private lazy var tagView: UIImageView = {
let imageView = UIImageView()
imageView.xs_setCornerRadius(topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 4)
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 16, weight: .medium)
label.textColor = .white
label.numberOfLines = 2
return label
}()
private lazy var epLabel: UILabel = {
let label = XSLabel()
label.font = .font(ofSize: 14, weight: .regular)
label.colorImage = UIImage(named: "gradient_color_image_01")
return label
}()
private lazy var desLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .regular)
label.textColor = .white.withAlphaComponent(0.8)
label.numberOfLines = 2
return label
}()
private lazy var categoryView: UIView = {
let view = UIView()
view.layer.cornerRadius = 2
view.layer.masksToBounds = true
view.backgroundColor = .D_9_D_9_D_9.withAlphaComponent(0.16)
return view
}()
private lazy var categoryLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 10, weight: .regular)
label.textColor = .white
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.text = "The Year You Were Gone fro...fdsafdafdafdsfsafdsafdasfasdfsa"
epLabel.text = "55 Episodes"
desLabel.text = "Maddie, a maid, seeks justice for her murdered father. She u...fdsafdasfsfdads"
categoryLabel.text = "Angst Romance"
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSHomeNewCell {
private func xs_setupUI() {
contentView.addSubview(coverImageView)
coverImageView.addSubview(tagView)
contentView.addSubview(titleLabel)
contentView.addSubview(epLabel)
contentView.addSubview(desLabel)
contentView.addSubview(categoryView)
categoryView.addSubview(categoryLabel)
coverImageView.snp.makeConstraints { make in
make.left.top.bottom.equalToSuperview()
make.width.equalTo(105)
}
tagView.snp.makeConstraints { make in
make.left.top.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.left.equalTo(coverImageView.snp.right).offset(12)
make.right.lessThanOrEqualToSuperview()
make.top.equalToSuperview().offset(5)
}
epLabel.snp.makeConstraints { make in
make.left.equalTo(titleLabel)
make.top.equalTo(titleLabel.snp.bottom).offset(6)
}
desLabel.snp.makeConstraints { make in
make.left.equalTo(titleLabel)
make.top.equalTo(epLabel.snp.bottom).offset(6)
make.right.lessThanOrEqualToSuperview()
}
categoryView.snp.makeConstraints { make in
make.left.equalTo(titleLabel)
make.top.equalTo(desLabel.snp.bottom).offset(6)
make.height.equalTo(18)
}
categoryLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.left.equalToSuperview().offset(6)
}
}
}

View File

@ -0,0 +1,34 @@
//
// XSHomeNewTitleView.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
import SnapKit
class XSHomeNewTitleView: UICollectionReusableView {
lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .bold)
label.textColor = .FFDAA_4
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(titleLabel)
titleLabel.snp.makeConstraints { make in
make.left.equalToSuperview()
make.bottom.equalToSuperview().offset(-14)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,139 @@
//
// XSHomePopularBigCell.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import SnapKit
class XSHomePopularBigCell: UICollectionViewCell {
var model: XSShortModel? {
didSet {
coverImageView.xs_setImage(model?.image_url)
titleLabel.text = model?.name
desLabel.text = model?.xs_description
if let category = model?.category?.first, !category.isEmpty {
categoryLabel.text = category
categoryBgView.isHidden = false
} else {
categoryBgView.isHidden = true
}
if model?.tag_type == .hot {
tagView.isHidden = false
tagView.image = UIImage(named: "hot_icon_02")
} else if model?.tag_type == .new {
tagView.isHidden = false
tagView.image = UIImage(named: "new_icon_01")
} else {
tagView.isHidden = true
}
}
}
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.layer.masksToBounds = true
imageView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
imageView.layer.cornerRadius = 4
return imageView
}()
private lazy var tagView: UIImageView = {
let imageView = UIImageView()
imageView.xs_setCornerRadius(topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 4)
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .bold)
label.textColor = .white
return label
}()
private lazy var desLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 10, weight: .regular)
label.textColor = .white.withAlphaComponent(0.5)
label.numberOfLines = 2
return label
}()
private lazy var categoryBgView: UIView = {
let view = UIView()
view.layer.cornerRadius = 12
view.layer.masksToBounds = true
view.layer.borderWidth = 1
view.layer.borderColor = UIColor.white.withAlphaComponent(0.25).cgColor
return view
}()
private lazy var categoryLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .regular)
label.textColor = .white
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSHomePopularBigCell {
private func xs_setupUI() {
contentView.addSubview(coverImageView)
coverImageView.addSubview(tagView)
contentView.addSubview(titleLabel)
contentView.addSubview(desLabel)
contentView.addSubview(categoryBgView)
categoryBgView.addSubview(categoryLabel)
coverImageView.snp.makeConstraints { make in
make.left.right.top.equalToSuperview()
make.bottom.equalToSuperview().offset(-92)
}
tagView.snp.makeConstraints { make in
make.left.top.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.left.equalToSuperview()
make.right.lessThanOrEqualToSuperview()
make.top.equalTo(coverImageView.snp.bottom).offset(8)
}
desLabel.snp.makeConstraints { make in
make.left.equalToSuperview()
make.right.lessThanOrEqualToSuperview()
make.top.equalTo(titleLabel.snp.bottom).offset(4)
}
categoryBgView.snp.makeConstraints { make in
make.left.equalToSuperview()
make.right.lessThanOrEqualToSuperview()
make.bottom.equalToSuperview()
make.height.equalTo(24)
}
categoryLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.left.equalToSuperview().offset(8)
}
}
}

View File

@ -0,0 +1,101 @@
//
// XSHomePopularCell.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import SnapKit
class XSHomePopularCell: UICollectionViewCell {
var model: XSShortModel? {
didSet {
coverImageView.xs_setImage(model?.image_url)
titleLabel.text = model?.name
categoryLabel.text = model?.category?.first
if model?.tag_type == .hot {
tagView.isHidden = false
tagView.image = UIImage(named: "hot_icon_02")
} else if model?.tag_type == .new {
tagView.isHidden = false
tagView.image = UIImage(named: "new_icon_01")
} else {
tagView.isHidden = true
}
}
}
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.layer.cornerRadius = 4
return imageView
}()
private lazy var tagView: UIImageView = {
let imageView = UIImageView()
imageView.xs_setCornerRadius(topLeft: 0, topRight: 0, bottomLeft: 4, bottomRight: 0)
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .regular)
label.textColor = .white
label.numberOfLines = 2
return label
}()
private lazy var categoryLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .regular)
label.textColor = .white.withAlphaComponent(0.5)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
categoryLabel.text = "Revenge"
titleLabel.text = "The Year You Were Gone frfdsafhdaskhfkashfkdsahfdask"
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSHomePopularCell {
private func xs_setupUI() {
contentView.addSubview(coverImageView)
coverImageView.addSubview(tagView)
contentView.addSubview(titleLabel)
contentView.addSubview(categoryLabel)
coverImageView.snp.makeConstraints { make in
make.left.right.top.equalToSuperview()
make.bottom.equalToSuperview().offset(-57)
}
tagView.snp.makeConstraints { make in
make.right.top.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.left.equalToSuperview()
make.top.equalTo(coverImageView.snp.bottom).offset(5)
make.right.lessThanOrEqualToSuperview()
}
categoryLabel.snp.makeConstraints { make in
make.left.equalToSuperview()
make.right.lessThanOrEqualToSuperview()
make.bottom.equalToSuperview()
}
}
}

View File

@ -0,0 +1,182 @@
//
// XSHomeRankingsCell.swift
// XSeri
//
// Created by 鸿 on 2025/12/31.
//
import UIKit
import SnapKit
class XSHomeRankingsCell: UICollectionViewCell {
var model: XSShortModel? {
didSet {
coverImageView.xs_setImage(model?.image_url)
titleLabel.text = model?.name
if let category = model?.category?.first, !category.isEmpty {
categoryLabel.text = category
categoryView.isHidden = false
} else {
categoryView.isHidden = true
}
hotView.setNeedsUpdateConfiguration()
}
}
var num: Int = 0 {
didSet {
numLabel.text = "\(num)"
switch num {
case 1:
numBgView.image = UIImage(named: "rankings_num_bg_01")
numLabel.textColor = ._783902
case 2:
numBgView.image = UIImage(named: "rankings_num_bg_02")
numLabel.textColor = .white
default:
numBgView.image = UIImage(named: "rankings_num_bg_03")
numLabel.textColor = .white
}
}
}
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.layer.cornerRadius = 3
return imageView
}()
private lazy var numBgView: UIImageView = {
let imageView = UIImageView()
return imageView
}()
private lazy var numLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 11, weight: .bold)
return label
}()
private lazy var hotView: UIButton = {
var configuration = UIButton.Configuration.plain()
configuration.contentInsets = .zero
configuration.image = UIImage(named: "hot_icon_01")
configuration.imagePadding = 4
configuration.imagePlacement = .leading
let button = UIButton(configuration: configuration)
button.isUserInteractionEnabled = false
button.configurationUpdateHandler = { [weak self] button in
guard let self = self else { return }
let string = NSNumber(value: self.model?.watch_total ?? 0).format()
button.configuration?.attributedTitle = AttributedString(string, attributes: AttributeContainer([
.font : UIFont.font(ofSize: 14, weight: .regular),
.foregroundColor : UIColor.white
]))
}
button.setContentHuggingPriority(.required, for: .horizontal)
button.setContentCompressionResistancePriority(.required, for: .horizontal)
return button
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .medium)
label.textColor = .white
label.numberOfLines = 2
return label
}()
private lazy var categoryView: UIView = {
let view = UIView()
view.backgroundColor = .white.withAlphaComponent(0.08)
view.layer.cornerRadius = 2
view.layer.masksToBounds = true
return view
}()
private lazy var categoryLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 10, weight: .regular)
label.textColor = .white
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white.withAlphaComponent(0.1)
contentView.layer.cornerRadius = 6
contentView.layer.masksToBounds = true
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSHomeRankingsCell {
private func xs_setupUI() {
contentView.addSubview(coverImageView)
coverImageView.addSubview(numBgView)
numBgView.addSubview(numLabel)
contentView.addSubview(hotView)
contentView.addSubview(titleLabel)
contentView.addSubview(categoryView)
categoryView.addSubview(categoryLabel)
coverImageView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.left.equalToSuperview().offset(12)
make.width.equalTo(45)
make.height.equalTo(60)
}
numBgView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(-3)
make.left.equalToSuperview().offset(-3)
}
numLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview().offset(-2)
make.centerY.equalToSuperview().offset(-1)
}
hotView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.right.equalToSuperview().offset(-12)
}
titleLabel.snp.makeConstraints { make in
make.left.equalTo(coverImageView.snp.right).offset(19)
make.right.lessThanOrEqualTo(hotView.snp.left).offset(-15)
make.top.equalToSuperview().offset(12)
}
categoryView.snp.makeConstraints { make in
make.left.equalTo(titleLabel)
make.right.lessThanOrEqualTo(hotView.snp.left).offset(-15)
make.top.equalTo(titleLabel.snp.bottom).offset(10)
make.height.equalTo(18)
}
categoryLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.left.equalToSuperview().offset(6)
}
}
}

View File

@ -0,0 +1,58 @@
//
// XSHomeSearchButton.swift
// XSeri
//
// Created by 鸿 on 2025/12/30.
//
import UIKit
import SnapKit
class XSHomeSearchButton: UIControl {
override var intrinsicContentSize: CGSize {
return .init(width: XSScreen.width, height: 32)
}
private lazy var iconImageView = UIImageView(image: UIImage(named: "search_icon_01"))
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .medium)
label.textColor = .white.withAlphaComponent(0.6)
label.text = "Through the Storm".localized
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white.withAlphaComponent(0.1)
layer.cornerRadius = 8
layer.masksToBounds = true
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSHomeSearchButton {
private func xs_setupUI() {
addSubview(iconImageView)
addSubview(titleLabel)
iconImageView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.left.equalToSuperview().offset(12)
}
titleLabel.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.left.equalTo(iconImageView.snp.right).offset(8)
make.right.lessThanOrEqualToSuperview().offset(-12)
}
}
}

View File

@ -0,0 +1,58 @@
//
// XSSearchGradientButton.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
///
final class XSSearchGradientButton: UIControl {
private let gradientLayer = CAGradientLayer()
private lazy var iconImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "search_icon_02"))
imageView.contentMode = .scaleAspectFit
return imageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
layer.cornerRadius = bounds.height / 2
}
}
extension XSSearchGradientButton {
private func xs_setupUI() {
// Figma
gradientLayer.colors = [
UIColor.F_5_BD_7_E.cgColor,
UIColor.FFEABC.cgColor,
UIColor.FFCF_99.cgColor
]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
layer.insertSublayer(gradientLayer, at: 0)
layer.masksToBounds = true
addSubview(iconImageView)
iconImageView.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
}

View File

@ -0,0 +1,231 @@
//
// XSSearchHeaderView.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
final class XSSearchHeaderView: UIView {
enum Style {
case home
case input
}
var text: String? {
get { textField.text }
set {
textField.text = newValue
xs_updateSearchButtonVisibility(newValue)
}
}
///
var didTapBack: (() -> Void)?
///
var didTapSearch: ((_ keyword: String?) -> Void)?
///
var didBeginEditing: ((_ keyword: String?) -> Void)?
///
var didChangeText: ((_ keyword: String?) -> Void)?
var style: Style = .home {
didSet {
xs_applyStyle()
}
}
var placeholder: String? {
didSet {
// 使
let text = placeholder ?? ""
textField.attributedPlaceholder = NSAttributedString(
string: text,
attributes: [
.foregroundColor: UIColor.white.withAlphaComponent(0.6),
.font: UIFont.font(ofSize: 14, weight: .medium)
]
)
}
}
private lazy var backButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(UIImage(named: "arrow_left_icon_02"), for: .normal)
button.tintColor = .white
button.addTarget(self, action: #selector(xs_handleBack), for: .touchUpInside)
return button
}()
private lazy var searchContainer: UIView = {
let view = UIView()
view.backgroundColor = UIColor.white.withAlphaComponent(0.18)
view.layer.cornerRadius = 19
view.layer.masksToBounds = true
return view
}()
private lazy var searchIconView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "search_icon_01"))
imageView.contentMode = .scaleAspectFit
return imageView
}()
private lazy var textField: UITextField = {
let textField = UITextField()
textField.textColor = .white
textField.font = .font(ofSize: 14, weight: .medium)
textField.clearButtonMode = .never
textField.returnKeyType = .search
textField.delegate = self
textField.addTarget(self, action: #selector(xs_textChanged), for: .editingChanged)
return textField
}()
private lazy var searchButton = XSSearchGradientButton()
private var containerHeightConstraint: Constraint?
private var textLeadingToSuperview: Constraint?
private var textLeadingToIcon: Constraint?
private var textTrailingToButton: Constraint?
private var textTrailingToSuperview: Constraint?
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
self.textField.becomeFirstResponder()
return result
}
}
extension XSSearchHeaderView {
private func xs_setupUI() {
addSubview(backButton)
addSubview(searchContainer)
searchContainer.addSubview(searchIconView)
searchContainer.addSubview(textField)
searchContainer.addSubview(searchButton)
searchButton.addTarget(self, action: #selector(xs_handleSearch), for: .touchUpInside)
backButton.snp.makeConstraints { make in
make.left.equalToSuperview()
make.centerY.equalToSuperview()
make.size.equalTo(CGSize(width: 24, height: 24))
}
searchContainer.snp.makeConstraints { make in
make.left.equalTo(backButton.snp.right).offset(8)
make.right.equalToSuperview()
make.centerY.equalToSuperview()
containerHeightConstraint = make.height.equalTo(38).constraint
}
searchIconView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(CGSize(width: 16, height: 16))
}
searchButton.snp.makeConstraints { make in
make.right.equalToSuperview().offset(-4)
make.centerY.equalToSuperview()
make.size.equalTo(CGSize(width: 57, height: 30))
}
textField.snp.makeConstraints { make in
textLeadingToSuperview = make.left.equalToSuperview().offset(20).constraint
textLeadingToIcon = make.left.equalTo(searchIconView.snp.right).offset(8).constraint
make.centerY.equalToSuperview()
textTrailingToButton = make.right.equalTo(searchButton.snp.left).offset(-8).constraint
textTrailingToSuperview = make.right.equalToSuperview().offset(-12).constraint
make.height.equalTo(30)
}
xs_applyStyle()
}
}
extension XSSearchHeaderView {
@objc private func xs_handleBack() {
didTapBack?()
}
@objc private func xs_handleSearch() {
didTapSearch?(textField.text)
}
@objc private func xs_textChanged() {
let value = textField.text
xs_updateSearchButtonVisibility(value)
didChangeText?(value)
}
}
extension XSSearchHeaderView: UITextFieldDelegate {
func textFieldDidBeginEditing(_ textField: UITextField) {
didBeginEditing?(textField.text)
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
didTapSearch?(textField.text)
textField.resignFirstResponder()
return true
}
}
extension XSSearchHeaderView {
private func xs_applyStyle() {
switch style {
case .home:
searchIconView.isHidden = true
searchContainer.backgroundColor = UIColor.white.withAlphaComponent(0.18)
searchContainer.layer.cornerRadius = 19
containerHeightConstraint?.update(offset: 38)
textLeadingToSuperview?.activate()
textLeadingToIcon?.deactivate()
case .input:
searchIconView.isHidden = false
searchContainer.backgroundColor = UIColor.white.withAlphaComponent(0.1)
searchContainer.layer.cornerRadius = 18
containerHeightConstraint?.update(offset: 36)
textLeadingToSuperview?.deactivate()
textLeadingToIcon?.activate()
}
xs_updateSearchButtonVisibility(textField.text)
}
private func xs_updateSearchButtonVisibility(_ text: String?) {
let keyword = (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let shouldShow: Bool
switch style {
case .home:
shouldShow = true
case .input:
shouldShow = !keyword.isEmpty
}
searchButton.isHidden = !shouldShow
if shouldShow {
textTrailingToSuperview?.deactivate()
textTrailingToButton?.activate()
} else {
textTrailingToButton?.deactivate()
textTrailingToSuperview?.activate()
}
layoutIfNeeded()
}
}

View File

@ -0,0 +1,110 @@
//
// XSSearchHistoryHotView.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
/// +
final class XSSearchHistoryHotView: UIView {
///
var hasContent: Bool {
return !historyTags.isEmpty || !hotSections.isEmpty
}
///
var didTapClear: (() -> Void)? {
didSet {
recentView.didTapClear = didTapClear
}
}
///
var didSelectHistory: ((_ text: String) -> Void)? {
didSet {
recentView.didSelectTag = didSelectHistory
}
}
var historyTitle: String? {
didSet {
recentView.title = historyTitle
}
}
var historyTags: [String] = [] {
didSet {
recentView.tags = historyTags
xs_updateVisibility()
}
}
var hotTitle: String? {
didSet {
hotSectionView.title = hotTitle
}
}
///
var didSelectHot: ((_ model: XSShortModel) -> Void)? {
didSet {
hotSectionView.didSelectItem = didSelectHot
}
}
var hotSections: [XSSearchHotSection] = [] {
didSet {
hotSectionView.sections = hotSections
xs_updateVisibility()
}
}
private let recentContainer = UIView()
private let recentView = XSSearchRecentView()
private let hotSectionView = XSSearchHotSectionView()
private let stackView: UIStackView = {
let view = UIStackView()
view.axis = .vertical
view.spacing = 24
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSSearchHistoryHotView {
private func xs_setupUI() {
addSubview(stackView)
recentContainer.addSubview(recentView)
stackView.addArrangedSubview(recentContainer)
stackView.addArrangedSubview(hotSectionView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
recentView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
}
xs_updateVisibility()
}
private func xs_updateVisibility() {
recentContainer.isHidden = historyTags.isEmpty
hotSectionView.isHidden = hotSections.isEmpty
}
}

View File

@ -0,0 +1,108 @@
//
// XSSearchHotListCardView.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
final class XSSearchHotListCardView: UIView {
///
var section: XSSearchHotSection? {
didSet {
xs_applySection()
}
}
///
var didSelectItem: ((_ model: XSShortModel) -> Void)?
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .bold)
return label
}()
private lazy var iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
return imageView
}()
private lazy var stackView: UIStackView = {
let view = UIStackView()
view.axis = .vertical
view.spacing = 8
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSSearchHotListCardView {
private func xs_setupUI() {
layer.cornerRadius = 12
layer.masksToBounds = true
layer.borderWidth = 1
addSubview(titleLabel)
addSubview(iconImageView)
addSubview(stackView)
iconImageView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.top.equalToSuperview().offset(20)
}
titleLabel.snp.makeConstraints { make in
make.centerY.equalTo(iconImageView)
make.left.equalTo(iconImageView.snp.right).offset(2)
make.right.lessThanOrEqualToSuperview().offset(-10)
}
stackView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.right.equalToSuperview().offset(-16)
make.top.equalToSuperview().offset(53)
}
}
private func xs_applySection() {
guard let section = section else { return }
titleLabel.text = section.title
iconImageView.image = section.icon
switch section.style {
case .gold:
layer.borderColor = UIColor.FDE_7_B_8.withAlphaComponent(0.4).cgColor
titleLabel.textColor = .FDE_7_B_8
case .purple:
layer.borderColor = UIColor.E_0_C_6_FF.withAlphaComponent(0.2).cgColor
titleLabel.textColor = UIColor.E_0_C_6_FF.withAlphaComponent(0.7)
}
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
section.items.enumerated().forEach { index, item in
let itemView = XSSearchHotListItemView()
itemView.configure(model: item, rank: index + 1, style: section.style)
itemView.didTap = { [weak self] model in
self?.didSelectItem?(model)
}
itemView.snp.makeConstraints { make in
make.height.equalTo(65)
}
stackView.addArrangedSubview(itemView)
}
}
}

View File

@ -0,0 +1,147 @@
//
// XSSearchHotListItemView.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
final class XSSearchHotListItemView: UIView {
///
var didTap: ((_ model: XSShortModel) -> Void)?
private var model: XSShortModel?
private lazy var rankBadgeView: XSView = {
let view = XSView()
view.xs_startPoint = .init(x: 0.5, y: 0)
view.xs_endPoint = .init(x: 0.5, y: 1)
view.layer.masksToBounds = true
return view
}()
private lazy var rankLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 10, weight: .bold)
return label
}()
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.layer.cornerRadius = 4
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 10, weight: .semibold)
label.textColor = UIColor.white.withAlphaComponent(0.9)
label.numberOfLines = 2
return label
}()
private lazy var categoryLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 10, weight: .medium)
label.textColor = UIColor.white.withAlphaComponent(0.6)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
rankBadgeView.layer.cornerRadius = rankBadgeView.bounds.height / 2
}
func configure(model: XSShortModel, rank: Int, style: XSSearchHotSectionStyle) {
//
self.model = model
rankLabel.text = "\(rank)"
titleLabel.text = model.name ?? model.xs_description
let category = model.category?.first ?? model.categoryList?.first?.name ?? ""
categoryLabel.text = category
categoryLabel.isHidden = category.isEmpty
coverImageView.xs_setImage(model.image_url)
switch style {
case .gold:
if rank <= 3 {
rankBadgeView.xs_colors = [UIColor.FFE_6_B_3.cgColor, UIColor.F_4_C_783.cgColor]
rankLabel.textColor = ._060606
} else {
rankBadgeView.xs_colors = [UIColor.clear.cgColor, UIColor.clear.cgColor]
rankLabel.textColor = .FCD_68_D
}
case .purple:
if rank <= 3 {
rankBadgeView.xs_colors = [UIColor.E_7_CCFF.cgColor, UIColor.D_7_BCFF.cgColor, UIColor.A_191_FF.cgColor]
rankLabel.textColor = .black
} else {
rankBadgeView.xs_colors = [UIColor.clear.cgColor, UIColor.clear.cgColor]
rankLabel.textColor = UIColor.B_4_A_0_FF
}
}
}
}
extension XSSearchHotListItemView {
private func xs_setupUI() {
let tap = UITapGestureRecognizer(target: self, action: #selector(xs_handleTap))
addGestureRecognizer(tap)
isUserInteractionEnabled = true
addSubview(rankBadgeView)
rankBadgeView.addSubview(rankLabel)
addSubview(coverImageView)
addSubview(titleLabel)
addSubview(categoryLabel)
rankBadgeView.snp.makeConstraints { make in
make.left.equalToSuperview()
make.centerY.equalToSuperview()
make.size.equalTo(CGSize(width: 14, height: 14))
}
rankLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
coverImageView.snp.makeConstraints { make in
make.left.equalTo(rankBadgeView.snp.right).offset(8)
make.centerY.equalToSuperview()
make.size.equalTo(CGSize(width: 48, height: 65))
}
titleLabel.snp.makeConstraints { make in
make.left.equalTo(coverImageView.snp.right).offset(10)
make.top.equalToSuperview()
make.right.lessThanOrEqualToSuperview()
}
categoryLabel.snp.makeConstraints { make in
make.left.equalTo(titleLabel)
make.top.equalTo(titleLabel.snp.bottom).offset(6)
make.right.lessThanOrEqualToSuperview()
}
}
}
extension XSSearchHotListItemView {
@objc private func xs_handleTap() {
guard let model = model else { return }
didTap?(model)
}
}

View File

@ -0,0 +1,115 @@
//
// XSSearchHotSectionView.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
final class XSSearchHotSectionView: UIView {
var title: String? {
didSet {
titleLabel.text = title
}
}
///
var sections: [XSSearchHotSection] = [] {
didSet {
xs_reloadSections()
}
}
///
var didSelectItem: ((_ model: XSShortModel) -> Void)?
private var cardViews: [XSSearchHotListCardView] = []
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .bold)
label.textColor = .white
return label
}()
private lazy var scrollView: UIScrollView = {
let view = UIScrollView()
view.showsHorizontalScrollIndicator = false
view.keyboardDismissMode = .onDrag
return view
}()
private let contentView = UIView()
private lazy var stackView: UIStackView = {
let view = UIStackView()
view.axis = .horizontal
view.spacing = 22
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSSearchHotSectionView {
private func xs_setupUI() {
addSubview(titleLabel)
addSubview(scrollView)
scrollView.addSubview(contentView)
contentView.addSubview(stackView)
titleLabel.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.right.equalToSuperview().offset(-16)
make.top.equalToSuperview()
}
scrollView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.left.right.bottom.equalToSuperview()
make.height.equalTo(435)
}
contentView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalToSuperview()
}
stackView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.left.equalToSuperview().offset(16)
make.right.equalToSuperview().offset(-16)
make.height.equalTo(435)
}
}
private func xs_reloadSections() {
cardViews.forEach { $0.removeFromSuperview() }
cardViews = sections.map { _ in XSSearchHotListCardView() }
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
for (index, section) in sections.enumerated() {
let cardView = cardViews[index]
cardView.section = section
cardView.didSelectItem = { [weak self] model in
self?.didSelectItem?(model)
}
stackView.addArrangedSubview(cardView)
cardView.snp.makeConstraints { make in
make.width.equalTo(237)
make.height.equalTo(435)
}
}
}
}

View File

@ -0,0 +1,101 @@
//
// XSSearchRecentView.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
final class XSSearchRecentView: UIView {
///
var didTapClear: (() -> Void)?
///
var didSelectTag: ((_ text: String) -> Void)? {
didSet {
tagsView.didSelectTag = didSelectTag
}
}
var title: String? {
didSet {
titleLabel.text = title
}
}
///
var tags: [String] = [] {
didSet {
tagsView.tags = tags
}
}
private var tagsHeightConstraint: Constraint?
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .bold)
label.textColor = .white
return label
}()
private lazy var clearButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(UIImage(named: "delete_icon_01"), for: .normal)
button.tintColor = .white.withAlphaComponent(0.6)
button.addTarget(self, action: #selector(xs_handleClear), for: .touchUpInside)
return button
}()
private lazy var tagsView: XSSearchTagsView = {
let view = XSSearchTagsView()
view.didChangeHeight = { [weak self] height in
self?.tagsHeightConstraint?.update(offset: height)
}
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSSearchRecentView {
private func xs_setupUI() {
addSubview(titleLabel)
addSubview(clearButton)
addSubview(tagsView)
titleLabel.snp.makeConstraints { make in
make.left.top.equalToSuperview()
}
clearButton.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.right.equalToSuperview()
make.size.equalTo(CGSize(width: 20, height: 20))
}
tagsView.snp.makeConstraints { make in
make.left.right.equalToSuperview()
make.top.equalTo(titleLabel.snp.bottom).offset(12)
tagsHeightConstraint = make.height.equalTo(0).constraint
make.bottom.equalToSuperview()
}
}
}
extension XSSearchRecentView {
@objc private func xs_handleClear() {
didTapClear?()
}
}

View File

@ -0,0 +1,63 @@
//
// XSSearchResultCell.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
final class XSSearchResultCell: UICollectionViewCell {
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.placeholderColor = ._1_F_0_B_00
imageView.layer.cornerRadius = 8
imageView.layer.borderWidth = 1
imageView.layer.borderColor = UIColor.white.withAlphaComponent(0.25).cgColor
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .bold)
label.textColor = .white
label.numberOfLines = 2
label.lineBreakMode = .byTruncatingTail
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(model: XSShortModel) {
coverImageView.xs_setImage(model.image_url)
titleLabel.text = model.name ?? model.xs_description
}
}
extension XSSearchResultCell {
private func xs_setupUI() {
contentView.addSubview(coverImageView)
contentView.addSubview(titleLabel)
coverImageView.snp.makeConstraints { make in
make.left.top.right.equalToSuperview()
make.height.equalTo(146)
}
titleLabel.snp.makeConstraints { make in
make.left.right.equalToSuperview()
make.top.equalTo(coverImageView.snp.bottom).offset(8)
make.bottom.lessThanOrEqualToSuperview()
}
}
}

View File

@ -0,0 +1,146 @@
//
// XSSearchSuggestionCell.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
final class XSSearchSuggestionCell: UICollectionViewCell {
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.placeholderColor = ._1_F_0_B_00
imageView.layer.cornerRadius = 7
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .semibold)
label.textColor = UIColor.white.withAlphaComponent(0.4)
label.numberOfLines = 1
label.lineBreakMode = .byTruncatingTail
return label
}()
private lazy var tagContainer: UIView = {
let view = UIView()
view.backgroundColor = UIColor.white.withAlphaComponent(0.1)
view.layer.cornerRadius = 6
view.layer.masksToBounds = true
return view
}()
private lazy var tagLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .regular)
label.textColor = .white
return label
}()
private lazy var epLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .regular)
label.textColor = .white
return label
}()
private lazy var infoStack: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 23
stackView.alignment = .leading
return stackView
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(model: XSShortModel, keyword: String) {
coverImageView.xs_setImage(model.image_url)
let title = model.name ?? model.xs_description ?? ""
titleLabel.attributedText = xs_highlight(text: title, keyword: keyword)
let category = model.category?.first ?? model.categoryList?.first?.name ?? ""
tagLabel.text = category
tagContainer.isHidden = category.isEmpty
if let current = model.current_episode, !current.isEmpty {
epLabel.text = "EP.##".localizedReplace(text: current)
} else if let total = model.episode_total {
epLabel.text = "EP.##".localizedReplace(text: "\(total)")
} else {
epLabel.text = ""
}
epLabel.isHidden = (epLabel.text ?? "").isEmpty
}
}
extension XSSearchSuggestionCell {
private func xs_setupUI() {
contentView.addSubview(coverImageView)
contentView.addSubview(infoStack)
tagContainer.addSubview(tagLabel)
infoStack.addArrangedSubview(titleLabel)
infoStack.addArrangedSubview(tagContainer)
infoStack.addArrangedSubview(epLabel)
coverImageView.snp.makeConstraints { make in
make.left.top.bottom.equalToSuperview()
make.size.equalTo(CGSize(width: 79, height: 104))
}
infoStack.snp.makeConstraints { make in
make.left.equalTo(coverImageView.snp.right).offset(12)
make.centerY.equalToSuperview()
make.right.lessThanOrEqualToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.width.equalToSuperview()
}
tagContainer.snp.makeConstraints { make in
make.height.equalTo(20)
}
tagLabel.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.left.equalToSuperview().offset(12)
make.right.equalToSuperview().offset(-12)
}
}
private func xs_highlight(text: String, keyword: String) -> NSAttributedString {
let baseColor = UIColor.white.withAlphaComponent(0.4)
let highlightColor = UIColor.white
let attributed = NSMutableAttributedString(
string: text,
attributes: [
.foregroundColor: baseColor
]
)
guard !keyword.isEmpty else { return attributed }
let lowerText = text.lowercased()
let lowerKeyword = keyword.lowercased()
var searchRange = lowerText.startIndex..<lowerText.endIndex
//
while let range = lowerText.range(of: lowerKeyword, options: [], range: searchRange) {
let nsRange = NSRange(range, in: text)
attributed.addAttribute(.foregroundColor, value: highlightColor, range: nsRange)
searchRange = range.upperBound..<lowerText.endIndex
}
return attributed
}
}

View File

@ -0,0 +1,54 @@
//
// XSSearchTagCell.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
final class XSSearchTagCell: UICollectionViewCell {
static let textFont = UIFont.font(ofSize: 12, weight: .bold)
var title: String? {
didSet {
titleLabel.text = title
}
}
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = Self.textFont
label.textColor = .white
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = UIColor.white.withAlphaComponent(0.18)
contentView.layer.masksToBounds = true
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
contentView.layer.cornerRadius = contentView.bounds.height / 2
}
}
extension XSSearchTagCell {
private func xs_setupUI() {
contentView.addSubview(titleLabel)
titleLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
}

View File

@ -0,0 +1,110 @@
//
// XSSearchTagsView.swift
// XSeri
//
// Created by 鸿 on 2026/2/2.
//
import UIKit
import SnapKit
import collection_view_layouts
final class XSSearchTagsView: UIView {
var didChangeHeight: ((_ height: CGFloat) -> Void)?
///
var didSelectTag: ((_ text: String) -> Void)?
private var lastLayoutWidth: CGFloat = 0
///
var tags: [String] = [] {
didSet {
collectionView.reloadData()
}
}
private lazy var layout: TagsLayout = {
let layout = TagsLayout()
layout.delegate = self
layout.cellsPadding = .init(horizontal: 12, vertical: 12)
layout.contentPadding = .init(horizontal: 0, vertical: 0)
return layout
}()
private lazy var collectionView: XSCollectionView = {
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.isScrollEnabled = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.addObserver(self, forKeyPath: "contentSize", context: nil)
collectionView.register(XSSearchTagCell.self, forCellWithReuseIdentifier: "cell")
return collectionView
}()
deinit {
collectionView.removeObserver(self, forKeyPath: "contentSize")
}
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard keyPath == "contentSize" else { return }
didChangeHeight?(collectionView.contentSize.height)
}
override func layoutSubviews() {
super.layoutSubviews()
//
if bounds.width != lastLayoutWidth {
lastLayoutWidth = bounds.width
collectionView.collectionViewLayout.invalidateLayout()
}
}
}
extension XSSearchTagsView {
private func xs_setupUI() {
addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}
// MARK: - UICollectionViewDelegate UICollectionViewDataSource
extension XSSearchTagsView: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return tags.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSSearchTagCell
cell.title = tags[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
didSelectTag?(tags[indexPath.row])
}
}
// MARK: - LayoutDelegate
extension XSSearchTagsView: LayoutDelegate {
func cellSize(indexPath: IndexPath) -> CGSize {
let text = tags[indexPath.row]
let width = text.size(XSSearchTagCell.textFont, .init(width: XSScreen.width, height: 24)).width
return .init(width: width + 20, height: 24)
}
}

View File

@ -0,0 +1,41 @@
//
// XSHomeViewModel.swift
// XSeri
//
// Created by 鸿 on 2026/1/12.
//
import UIKit
@MainActor
class XSHomeViewModel: NSObject {
var dataArr: [XSHomeModuleItem] = []
func requestHomeData() async {
guard let list = await XSHomeAPI.requestHomeData() else { return }
self.dataArr.removeAll()
var popularItem: XSHomeModuleItem?
var rankingsItem: XSHomeModuleItem?
list.forEach {
if $0.module_key == .week_highest_recommend {
popularItem = $0
} else if $0.module_key == .week_ranking {
rankingsItem = $0
}
}
if let item = popularItem {
self.dataArr.append(item)
}
if let item = rankingsItem {
self.dataArr.append(item)
}
}
}

View File

@ -0,0 +1,94 @@
//
// XSAboutViewController.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
import SnapKit
class XSAboutViewController: XSCommonViewController {
private lazy var dataArr: [XSMineItem] = {
let arr = [
XSMineItem(type: .web, title: "Privacy Policy".localized, url: kXSPrivacyPolicyWebUrl),
XSMineItem(type: .web, title: "User Agreement".localized, url: kXSUserAgreementWebUrl),
XSMineItem(type: .safari, title: "Visit Website".localized, url: XSWebBaseURL),
]
return arr
}()
private lazy var tableView: XSTableView = {
let tableView = XSTableView(frame: .zero, style: .grouped)
tableView.delegate = self
tableView.dataSource = self
tableView.separatorStyle = .none
tableView.register(XSAboutCell.self, forCellReuseIdentifier: "cell")
tableView.register(XSAboutHeaderView.self, forHeaderFooterViewReuseIdentifier: "header")
return tableView
}()
override func viewDidLoad() {
super.viewDidLoad()
self.title = "About".localized
xs_setupUI()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(false, animated: true)
self.xs_setNavigationStyle()
}
}
extension XSAboutViewController {
private func xs_setupUI() {
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalTo(self.view.safeAreaLayoutGuide)
}
}
}
//MARK: UITableViewDelegate UITableViewDataSource
extension XSAboutViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! XSAboutCell
cell.item = self.dataArr[indexPath.row]
return cell
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.dataArr.count
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: "header") as? XSAboutHeaderView
return view
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = self.dataArr[indexPath.row]
guard let urlStr = item.url else { return }
if item.type == .web {
let vc = XSBaseWebViewController()
vc.webUrl = urlStr
self.navigationController?.pushViewController(vc, animated: true)
} else {
if let url = URL(string: urlStr) {
UIApplication.shared.open(url)
}
}
}
}

View File

@ -0,0 +1,97 @@
//
// XSFeedbackViewController.swift
// XSeri
//
// Created by 鸿 on 2026/2/28.
//
import UIKit
import SnapKit
class XSFeedbackViewController: XSAppWebViewController {
private lazy var rightButton: UIButton = {
let button = UIButton(type: .custom)
button.setImage(UIImage(named: "feedback_icon_02"), for: .normal)
button.addTarget(self, action: #selector(handleRightBarButton), for: .touchUpInside)
return button
}()
private lazy var redView: UIView = {
let view = UIView()
view.backgroundColor = .FF_313_B
view.layer.cornerRadius = 8
view.isHidden = true
return view
}()
private lazy var redLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .bold)
label.textColor = .white
return label
}()
override func viewDidLoad() {
self.webUrl = kXSFeedBackHomeWebUrl
super.viewDidLoad()
self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: rightButton)
xs_setupUI()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
await self.requestRedCount()
}
}
@objc private func handleRightBarButton() {
let vc = XSAppWebViewController()
vc.webUrl = kXSFeedBackListWebUrl
self.navigationController?.pushViewController(vc, animated: true)
}
}
extension XSFeedbackViewController {
private func xs_setupUI() {
rightButton.addSubview(redView)
redView.addSubview(redLabel)
redView.snp.makeConstraints { make in
make.height.equalTo(16)
make.width.greaterThanOrEqualTo(16)
make.top.equalToSuperview().offset(-8)
make.right.equalToSuperview().offset(8)
}
redLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.left.greaterThanOrEqualToSuperview().offset(3)
}
}
}
extension XSFeedbackViewController {
private func requestRedCount() async {
guard let model = await XSSettingAPI.requestFeedbackRedCount() else { return }
if let count = model.feedback_notice_num, count > 0 {
self.redView.isHidden = false
self.redLabel.text = "\(count)"
} else {
self.redView.isHidden = true
}
}
}

View File

@ -0,0 +1,161 @@
//
// XSMineViewController.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
import SnapKit
class XSMineViewController: XSCommonViewController {
private lazy var dataArr: [XSMineItem] = []
private lazy var historyDataArr: [XSShortModel] = []
private lazy var logoImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "logo_icon_01"))
return imageView
}()
private lazy var tableView: XSTableView = {
let tableView = XSTableView(frame: .zero, style: .grouped)
tableView.delegate = self
tableView.dataSource = self
tableView.separatorStyle = .none
tableView.rowHeight = 44
tableView.xs_addRefreshHeader { [weak self] in
guard let self = self else { return }
self.handleHeaderRefresh(nil)
}
tableView.register(XSMineCell.self, forCellReuseIdentifier: "cell")
tableView.register(XSMineHeaderView.self, forHeaderFooterViewReuseIdentifier: "header")
return tableView
}()
deinit {
NotificationCenter.default.removeObserver(self)
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(networkStatusDidChangeNotification), name: XSNetworkMonitorManager.networkStatusDidChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userInfoUpdateNotification), name: XSLoginManager.userInfoUpdateNotification, object: nil)
self.createDataArr()
xs_setupUI()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.setNavigationBarHidden(true, animated: true)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.createDataArr()
Task {
await XSLoginManager.manager.updateUserInfo()
await self.requestPlayHistory()
}
}
override func handleHeaderRefresh(_ completer: (() -> Void)?) {
Task {
await XSLoginManager.manager.updateUserInfo()
await self.requestPlayHistory()
self.tableView.xs_endHeaderRefreshing()
}
}
@objc private func userInfoUpdateNotification() {
self.tableView.reloadData()
}
@objc private func networkStatusDidChangeNotification() {
Task {
await XSLoginManager.manager.updateUserInfo()
await self.requestPlayHistory()
}
}
}
extension XSMineViewController {
private func xs_setupUI() {
view.addSubview(logoImageView)
view.addSubview(self.tableView)
logoImageView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.top.equalTo(self.view.safeAreaLayoutGuide).offset(9)
}
self.tableView.snp.makeConstraints { make in
make.left.right.bottom.equalToSuperview()
make.top.equalTo(logoImageView.snp.bottom).offset(16)
}
}
}
//MARK: UITableViewDelegate UITableViewDataSource
extension XSMineViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! XSMineCell
cell.item = self.dataArr[indexPath.row]
return cell
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.dataArr.count
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: "header") as? XSMineHeaderView
view?.userInfo = XSLoginManager.manager.userInfo
view?.historyDataArr = self.historyDataArr
view?.updateLayout()
return view
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = self.dataArr[indexPath.row]
switch item.type {
case .about:
let vc = XSAboutViewController()
self.navigationController?.pushViewController(vc, animated: true)
case .feedback:
let vc = XSFeedbackViewController()
self.navigationController?.pushViewController(vc, animated: true)
default:
break
}
}
}
extension XSMineViewController {
private func createDataArr() {
let arr = [
// XSMineItem(type: .setting, iconImage: UIImage(named: "setting_icon_01"), title: "Setting".localized),
XSMineItem(type: .about, iconImage: UIImage(named: "about_icon_01"), title: "About".localized),
XSMineItem(type: .feedback, iconImage: UIImage(named: "feedback_icon_01"), title: "Feedback".localized)
]
self.dataArr = arr
self.tableView.reloadData()
}
private func requestPlayHistory() async {
guard let list = await XSVideoAPI.requestPlayHistorys(page: 1) else { return }
self.historyDataArr = list
self.tableView.reloadData()
}
}

View File

@ -0,0 +1,13 @@
//
// XSFeedbackCountModel.swift
// XSeri
//
// Created by 鸿 on 2026/2/28.
//
import UIKit
import SmartCodable
struct XSFeedbackCountModel: SmartCodable {
var feedback_notice_num: Int?
}

View File

@ -0,0 +1,27 @@
//
// XSMineItem.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
struct XSMineItem {
enum ItemType {
case setting
case about
case feedback
case web
case safari
}
var type: ItemType?
var iconImage: UIImage?
var title: String?
var url: String?
}

View File

@ -0,0 +1,62 @@
//
// XSAboutCell.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
import SnapKit
class XSAboutCell: XSTableViewCell {
var item: XSMineItem? {
didSet {
titleLabel.text = item?.title
}
}
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .semibold)
label.textColor = .white
return label
}()
private lazy var lineView: UIView = {
let view = UIView()
view.backgroundColor = .white.withAlphaComponent(0.1)
return view
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
xs_indicatorImageView.image = UIImage(named: "arrow_right_icon_02")
contentView.addSubview(titleLabel)
contentView.addSubview(xs_indicatorImageView)
contentView.addSubview(lineView)
titleLabel.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.left.equalToSuperview().offset(30)
}
xs_indicatorImageView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.right.equalToSuperview().offset(-30)
}
lineView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(30)
make.centerX.equalToSuperview()
make.bottom.equalToSuperview()
make.height.equalTo(0.6)
}
}
@MainActor required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,64 @@
//
// XSAboutHeaderView.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
import SnapKit
class XSAboutHeaderView: UITableViewHeaderFooterView {
private lazy var logoImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "logo_icon_02"))
return imageView
}()
private lazy var appNameLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 18, weight: .semibold)
label.textColor = .white
label.text = kXSName
return label
}()
private lazy var versionLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .regular)
label.textColor = .FDE_7_B_8
label.text = "Version " + kXSVersion
return label
}()
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
automaticallyUpdatesBackgroundConfiguration = false
contentView.addSubview(logoImageView)
contentView.addSubview(appNameLabel)
contentView.addSubview(versionLabel)
logoImageView.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalToSuperview().offset(30)
}
appNameLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalTo(logoImageView.snp.bottom).offset(7)
}
versionLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalTo(appNameLabel.snp.bottom).offset(7)
make.bottom.equalToSuperview().offset(-30)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,66 @@
//
// XSMineCell.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
import SnapKit
class XSMineCell: XSTableViewCell {
var item: XSMineItem? {
didSet {
iconImageView.image = item?.iconImage
titleLabel.text = item?.title
}
}
private lazy var iconImageView: UIImageView = {
let imageView = UIImageView()
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .bold)
label.textColor = .FFDAA_4
return label
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
xs_setupUI()
}
@MainActor required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSMineCell {
private func xs_setupUI() {
contentView.addSubview(iconImageView)
contentView.addSubview(titleLabel)
contentView.addSubview(xs_indicatorImageView)
iconImageView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.left.equalToSuperview().offset(16)
}
titleLabel.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.left.equalToSuperview().offset(44)
}
xs_indicatorImageView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.right.equalToSuperview().offset(-16)
}
}
}

View File

@ -0,0 +1,78 @@
//
// XSMineHeaderView.swift
// XSeri
//
// Created by 鸿 on 2026/2/12.
//
import UIKit
import SnapKit
class XSMineHeaderView: UITableViewHeaderFooterView {
var userInfo: XSUserInfo? {
didSet {
userInfoView.userInfo = userInfo
}
}
var historyDataArr: [XSShortModel] = [] {
didSet {
historyView.dataArr = historyDataArr
}
}
private lazy var stackView: UIStackView = {
let view = UIStackView()
view.axis = .vertical
view.spacing = 16
return view
}()
private lazy var userInfoView: XSMineUserInfoView = {
let view = XSMineUserInfoView()
return view
}()
private lazy var historyView: XSMinePlayHistoryView = {
let view = XSMinePlayHistoryView()
return view
}()
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateLayout() {
stackView.xs_removeAllArrangedSubview()
stackView.addArrangedSubview(userInfoView)
if !historyDataArr.isEmpty {
stackView.addArrangedSubview(historyView)
}
}
}
extension XSMineHeaderView {
private func xs_setupUI() {
contentView.addSubview(stackView)
stackView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(6)
make.bottom.equalToSuperview().offset(-20)
make.left.right.equalToSuperview()
}
}
}

View File

@ -0,0 +1,86 @@
//
// XSMinePlayHistoryCell.swift
// XSeri
//
// Created by 鸿 on 2026/2/26.
//
import UIKit
import SnapKit
class XSMinePlayHistoryCell: UICollectionViewCell {
var model: XSShortModel? {
didSet {
coverImageView.xs_setImage(model?.image_url)
titleLabel.text = model?.name
let epString = NSMutableAttributedString(string: "EP.\(model?.current_episode ?? "0")")
epString.yy_color = .FFDAA_4
let totalEpString = NSMutableAttributedString(string: "/\(model?.episode_total ?? 0)")
totalEpString.yy_color = .white.withAlphaComponent(0.4)
epString.append(totalEpString)
epLabel.attributedText = epString
}
}
private lazy var coverImageView: XSImageView = {
let imageView = XSImageView()
imageView.layer.cornerRadius = 4
return imageView
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .medium)
label.textColor = .white
label.numberOfLines = 2
return label
}()
private lazy var epLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .medium)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSMinePlayHistoryCell {
private func xs_setupUI() {
contentView.addSubview(coverImageView)
contentView.addSubview(titleLabel)
contentView.addSubview(epLabel)
coverImageView.snp.makeConstraints { make in
make.left.right.top.equalToSuperview()
make.bottom.equalToSuperview().offset(-58)
}
titleLabel.snp.makeConstraints { make in
make.left.equalToSuperview()
make.top.equalTo(coverImageView.snp.bottom).offset(12)
make.right.lessThanOrEqualToSuperview()
}
epLabel.snp.makeConstraints { make in
make.left.equalToSuperview()
make.bottom.equalToSuperview()
}
}
}

View File

@ -0,0 +1,129 @@
//
// XSMinePlayHistoryView.swift
// XSeri
//
// Created by 鸿 on 2026/2/26.
//
import UIKit
import SnapKit
class XSMinePlayHistoryView: UIView {
var dataArr: [XSShortModel] = [] {
didSet {
self.collectionView.reloadData()
}
}
private lazy var moreButton: UIControl = {
let button = UIButton(primaryAction: UIAction(handler: { [weak self] _ in
guard let self = self else { return }
self.viewController?.tabBarController?.selectedIndex = 2
}))
return button
}()
private lazy var iconImageView = UIImageView(image: UIImage(named: "history_icon_01"))
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 14, weight: .bold)
label.textColor = .FFDAA_4
label.text = "Browsing History".localized
return label
}()
private lazy var indicatorImageView = UIImageView(image: UIImage(named: "arrow_right_icon_01"))
private lazy var collectionViewLayout: UICollectionViewFlowLayout = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.itemSize = .init(width: 109, height: 203)
layout.minimumLineSpacing = 8
return layout
}()
private lazy var collectionView: XSCollectionView = {
let collectionView = XSCollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.showsHorizontalScrollIndicator = false
collectionView.contentInset = .init(top: 0, left: 16, bottom: 0, right: 16)
collectionView.register(XSMinePlayHistoryCell.self, forCellWithReuseIdentifier: "cell")
return collectionView
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSMinePlayHistoryView {
private func xs_setupUI() {
addSubview(moreButton)
moreButton.addSubview(iconImageView)
moreButton.addSubview(titleLabel)
moreButton.addSubview(indicatorImageView)
addSubview(collectionView)
moreButton.snp.makeConstraints { make in
make.left.right.equalToSuperview()
make.top.equalToSuperview()
make.height.equalTo(20)
}
iconImageView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.left.equalTo(iconImageView.snp.right).offset(8)
}
indicatorImageView.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.right.equalToSuperview().offset(-16)
}
collectionView.snp.makeConstraints { make in
make.left.right.equalToSuperview()
make.top.equalTo(moreButton.snp.bottom).offset(23)
make.height.equalTo(self.collectionViewLayout.itemSize.height)
make.bottom.equalToSuperview()
}
}
}
//MARK: UICollectionViewDelegate UICollectionViewDataSource
extension XSMinePlayHistoryView: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! XSMinePlayHistoryCell
cell.model = self.dataArr[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataArr.count
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let model = self.dataArr[indexPath.row]
let vc = XSShortDetailViewController()
vc.shortId = model.short_play_id
self.viewController?.navigationController?.pushViewController(vc, animated: true)
}
}

View File

@ -0,0 +1,79 @@
//
// XSMineUserInfoView.swift
// XSeri
//
// Created by 鸿 on 2026/2/26.
//
import UIKit
import SnapKit
class XSMineUserInfoView: UIView {
var userInfo: XSUserInfo? {
didSet {
avatarImageView.xs_setImage(userInfo?.avator, placeholder: UIImage(named: "avatar_placeholder_icon_01"))
nickNameLabel.text = userInfo?.getNickName()
idLabel.text = "ID: \(userInfo?.customer_id ?? "")"
}
}
private lazy var avatarImageView: XSImageView = {
let imageView = XSImageView()
imageView.layer.cornerRadius = 28
return imageView
}()
private lazy var nickNameLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 16, weight: .bold)
label.textColor = .white
return label
}()
private lazy var idLabel: UILabel = {
let label = UILabel()
label.font = .font(ofSize: 12, weight: .medium)
label.textColor = .white.withAlphaComponent(0.6)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
xs_setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension XSMineUserInfoView {
private func xs_setupUI() {
addSubview(avatarImageView)
addSubview(nickNameLabel)
addSubview(idLabel)
avatarImageView.snp.makeConstraints { make in
make.left.equalToSuperview().offset(16)
make.top.equalToSuperview().offset(4)
make.height.width.equalTo(56)
make.bottom.equalToSuperview().offset(-4)
}
nickNameLabel.snp.makeConstraints { make in
make.left.equalTo(avatarImageView.snp.right).offset(10)
make.top.equalTo(avatarImageView).offset(8)
}
idLabel.snp.makeConstraints { make in
make.left.equalTo(nickNameLabel)
make.bottom.equalTo(avatarImageView).offset(-6)
}
}
}

Some files were not shown because too many files have changed in this diff Show More