custom explore page + api

This commit is contained in:
leminlimez committed 2025-07-15 15:00:42 -04:00
1 parent 5a94ea73d9
commit 4d6b093ffd
10 files changed
+415 -49

No files matched your search

+17
View File
@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; };
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; };
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -31,6 +32,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */,
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */,
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
);
@@ -77,6 +79,7 @@
packageProductDependencies = (
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
6F09BA322E00808300CDE89C /* Dynamic */,
6FB105542E26D3EC00185A5C /* CachedAsyncImage */,
);
productName = "Pocket Poster";
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
@@ -109,6 +112,7 @@
packageReferences = (
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */,
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
@@ -366,6 +370,14 @@
minimumVersion = 1.2.0;
};
};
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.1.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -379,6 +391,11 @@
package = 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */;
productName = Dynamic;
};
6FB105542E26D3EC00185A5C /* CachedAsyncImage */ = {
isa = XCSwiftPackageProductDependency;
package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
productName = CachedAsyncImage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
@@ -1,5 +1,5 @@
{
"originHash" : "b781b75a76813cee1292db0b540a9eecd3df93961ced8a0063095503735dadb2",
"originHash" : "73a600cf677d3bb2b929c60349923a2a4e20e6a9ef10509bf043d2ad2229ca95",
"pins" : [
{
"identity" : "dynamic",
@@ -10,6 +10,15 @@
"version" : "1.2.0"
}
},
{
"identity" : "swiftui-cached-async-image",
"kind" : "remoteSourceControl",
"location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
"state" : {
"revision" : "467a3d17479887943ab917a379e62bbaff60ac8a",
"version" : "2.1.1"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
+79
View File
@@ -0,0 +1,79 @@
//
// CowabungaAPI.swift
// Pocket Poster
//
// Created by lemin on 7/15/25.
//
import UIKit
enum FilterType: String, CaseIterable {
case random = "Random"
case newest = "Newest"
case oldest = "Oldest"
}
class CowabungaAPI: ObservableObject {
static let shared = CowabungaAPI()
var serverURL = ""
var session = URLSession.shared
func fetchWallpapers(type: DownloadableWallpaper.WallpaperType) async throws -> [DownloadableWallpaper] {
let request = URLRequest(url: .init(string: serverURL + "wallpapers-\(type.rawValue).json")!)
let (data, response) = try await session.data(for: request) as! (Data, HTTPURLResponse)
guard response.statusCode == 200 else { throw APIError.connectionFailed }
let wallpapers = try JSONDecoder().decode([DownloadableWallpaper].self, from: data)
for i in wallpapers.indices {
wallpapers[i].type = type
}
return wallpapers
}
func filterWallpapers(wallpapers: [DownloadableWallpaper], filterType: FilterType) -> [DownloadableWallpaper] {
var filtered = wallpapers
if filterType == FilterType.newest {
filtered = filtered.reversed()
} else if filterType == FilterType.random {
filtered = filtered.shuffled()
}
return filtered
}
func getCommitHash() async throws -> String {
let request = URLRequest(url: .init(string: serverURL + "https://api.github.com/repos/SerStars/nugget-wallpapers/commits/main")!)
let (data, response) = try await session.data(for: request) as! (Data, HTTPURLResponse)
guard response.statusCode == 200 else { throw APIError.connectionFailed }
guard let repoinfo = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { throw APIError.repoHashError }
guard let hash = repoinfo["sha"] as? String else { throw APIError.repoHashError }
return hash
}
func getDownloadURLForWallpaper(wallpaper: DownloadableWallpaper) -> URL {
if wallpaper.url.hasPrefix("https://") {
URL(string: wallpaper.url)!
} else {
URL(string: serverURL + wallpaper.url)!
}
}
func getPreviewURLForWallpaper(wallpaper: DownloadableWallpaper) -> URL {
URL(string: serverURL + wallpaper.preview)!
}
init() {
Task {
do {
let hash = try await getCommitHash()
serverURL = "https://raw.githubusercontent.com/SerStars/nugget-wallpapers/\(hash)/"
} catch {
await UIApplication.shared.alert(body: error.localizedDescription)
}
}
}
}
@@ -0,0 +1,27 @@
//
// DownloadableWallpaper.swift
// Pocket Poster
//
// Created by lemin on 7/15/25.
//
class DownloadableWallpaper: Identifiable, Codable {
var name: String
var description: String
var url: String
var preview: String
var authors: String
var type: WallpaperType?
init(name: String, description: String, authors: String, preview: String, url: String, version: String) {
self.name = name
self.description = description
self.authors = authors
self.preview = preview
self.url = url
}
enum WallpaperType: String, Codable {
case custom, apple, template
}
}
+30
View File
@@ -0,0 +1,30 @@
//
// APIError.swift
// Pocket Poster
//
// Created by lemin on 7/15/25.
//
import Foundation
enum APIError: LocalizedError {
// Throw when cannot connect to server
case connectionFailed
// Throw when it fails to get the repo hash
case repoHashError
// Throw in all other cases
case unexpected(info: String)
public var errorDescription: String? {
switch self {
case .connectionFailed:
return NSLocalizedString("Could not connect to server", comment: "")
case .repoHashError:
return NSLocalizedString("Unable to obtain repo hash. Maybe update to the latest version?", comment: "")
case .unexpected(let info):
return info
}
}
}
@@ -0,0 +1,14 @@
//
// URLCache+imageCache.swift
// Cowabunga
//
// Created by sourcelocation on 02/02/2023.
//
import CachedAsyncImage
import Foundation
extension URLCache {
static let imageCache = URLCache(memoryCapacity: 512*1000*1000, diskCapacity: 10*1000*1000*1000)
}
@@ -0,0 +1,25 @@
//
// CornerRadius++.swift
// Cowabunga
//
// Created by sourcelocation on 30/01/2023.
//
import SwiftUI
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape( RoundedCorner(radius: radius, corners: corners) )
}
}
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
return Path(path.cgPath)
}
}
+21 -6
View File
@@ -1,6 +1,9 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"Actions" : {
"localizations" : {
"ar" : {
@@ -462,6 +465,9 @@
}
}
}
},
"Apple" : {
},
"Apply" : {
"localizations" : {
@@ -1438,6 +1444,9 @@
}
}
}
},
"Could not connect to server" : {
},
"Could not download wallpaper!" : {
"localizations" : {
@@ -2210,6 +2219,9 @@
}
}
}
},
"Custom" : {
},
"Detect" : {
"localizations" : {
@@ -4229,6 +4241,9 @@
},
"Failed to fetch CarPlay wallpapers" : {
},
"Failed to fetch wallpapers" : {
},
"Failed to import tendies" : {
"localizations" : {
@@ -4537,9 +4552,6 @@
}
}
}
},
"Failed to load explore page." : {
},
"Fallback Shortcut Creator" : {
"localizations" : {
@@ -4848,6 +4860,9 @@
}
}
}
},
"Filter Wallpapers" : {
},
"Find Wallpapers" : {
"localizations" : {
@@ -8749,9 +8764,6 @@
}
}
}
},
"Retry" : {
},
"Select Tendies" : {
"localizations" : {
@@ -10603,6 +10615,9 @@
}
}
}
},
"Unable to obtain repo hash. Maybe update to the latest version?" : {
},
"Update Available" : {
"comment" : "app update available on GitHub",
@@ -6,41 +6,209 @@
//
import SwiftUI
import CachedAsyncImage
let MIN_SIZE: CGFloat = 165
struct ExploreView: View {
@State private var isLoading = true
@State private var error: Error? = nil
@ObservedObject var cowabungaAPI = CowabungaAPI.shared
#if DEBUG
@AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink
#endif
// lazyvgrid
@State private var gridItemLayout = [GridItem(.adaptive(minimum: MIN_SIZE))]
@State private var wallpapers: [DownloadableWallpaper] = []
@State var wallpaperTypeSelected = 0
@State var wallpaperTypeShown = DownloadableWallpaper.WallpaperType.custom
@State var filterType: FilterType = .random
@State var searchTerm: String = ""
var body: some View {
ZStack {
#if DEBUG
let websiteURL = URL(string: exploreLink)
#else
let websiteURL = URL(string: DownloadManager.exploreLink)
#endif
if let error = error {
NavigationView {
ScrollView {
PullToRefresh(coordinateSpaceName: "pullToRefresh") {
// refresh
wallpapers.removeAll()
Haptic.shared.play(.light)
//URLCache.imageCache.removeAllCachedResponses()
loadWallpapers()
}
VStack {
Text(error.localizedDescription)
.foregroundStyle(.red)
Button(action: {
isLoading = true
self.error = nil
}) {
Text("Retry")
Picker("", selection: $wallpaperTypeSelected) {
Text("Custom").tag(0)
Text("Apple").tag(1)
}
.pickerStyle(.segmented)
}
} else if let url = websiteURL {
WallpaperWebView(url, isLoading: $isLoading, error: $error)
if isLoading {
.padding(.horizontal, 24)
.padding(.bottom, 10)
if wallpapers.isEmpty {
ProgressView()
}
.scaleEffect(1.75)
.navigationTitle("Explore")
} else {
Text("Failed to load explore page.")
LazyVGrid(columns: gridItemLayout) {
ForEach(wallpapers) { wallpaper in
if searchTerm == "" || wallpaper.name.lowercased().contains(searchTerm.lowercased()) || wallpaper.authors.lowercased().contains(searchTerm.lowercased()) {
Button(action: {
DownloadManager.shared.startTendiesDownload(for: cowabungaAPI.getDownloadURLForWallpaper(wallpaper: wallpaper))
}) {
VStack(spacing: 0) {
CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity)
.cornerRadius(10, corners: .topLeft)
.cornerRadius(10, corners: .topRight)
} placeholder: {
Color.gray
.frame(height: MIN_SIZE)
}
HStack {
VStack(spacing: 4) {
HStack {
Text(wallpaper.name)
.foregroundStyle(Color(uiColor: .label))
.minimumScaleFactor(0.5)
Spacer()
}
HStack {
Text(wallpaper.authors)
.foregroundColor(.secondary)
.font(.caption)
.minimumScaleFactor(0.5)
Spacer()
}
}
.lineLimit(1)
Spacer()
Image(systemName: "arrow.down.circle")
.foregroundColor(.blue)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(height: 58)
}
}
.frame(minWidth: MIN_SIZE)
.background(Color(uiColor: .secondarySystemBackground))
.cornerRadius(10)
.padding(4)
}
}
}
.padding()
}
}
.searchable(text: $searchTerm)
.coordinateSpace(name: "pullToRefresh")
.navigationTitle("Explore")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showFilterChangerPopup()
}) {
Image(systemName: "line.3.horizontal.decrease.circle")
}
}
}
}
.onAppear {
loadWallpapers()
}
.onChange(of: wallpaperTypeSelected) { newValue in
let map = [0: DownloadableWallpaper.WallpaperType.custom, 1: .apple, 2: .template]
wallpaperTypeShown = map[newValue]!
wallpapers.removeAll()
loadWallpapers()
}
}
func loadWallpapers() {
Task {
do {
wallpapers = try await cowabungaAPI.fetchWallpapers(type: wallpaperTypeShown)
wallpapers = cowabungaAPI.filterWallpapers(wallpapers: wallpapers, filterType: filterType)
} catch {
UIApplication.shared.alert(title: NSLocalizedString("Failed to fetch wallpapers", comment: ""), body: error.localizedDescription)
}
}
}
func showFilterChangerPopup() {
// create and configure alert controller
let alert = UIAlertController(title: NSLocalizedString("Filter Wallpapers", comment: ""), message: "", preferredStyle: .actionSheet)
// create the actions
for type in FilterType.allCases {
let newAction = UIAlertAction(title: type.rawValue, style: .default) { (action) in
// apply the filter type
filterType = type
wallpapers.removeAll()
loadWallpapers()
}
if filterType == type {
// add a check mark
newAction.setValue(true, forKey: "checked")
}
alert.addAction(newAction)
}
let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { (action) in
// cancels the action
}
// add the actions
alert.addAction(cancelAction)
let view: UIView = UIApplication.shared.windows.first!.rootViewController!.view
// present popover for iPads
alert.popoverPresentationController?.sourceView = view // prevents crashing on iPads
alert.popoverPresentationController?.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY, width: 0, height: 0) // show up at center bottom on iPads
// present the alert
UIApplication.shared.windows.first?.rootViewController?.present(alert, animated: true)
}
}
struct PullToRefresh: View {
var coordinateSpaceName: String
var onRefresh: ()->Void
@State var needRefresh: Bool = false
var body: some View {
GeometryReader { geo in
if (geo.frame(in: .named(coordinateSpaceName)).midY > 50) {
Spacer()
.onAppear {
needRefresh = true
}
} else if (geo.frame(in: .named(coordinateSpaceName)).maxY < 10) {
Spacer()
.onAppear {
if needRefresh {
needRefresh = false
onRefresh()
}
}
}
HStack {
Spacer()
if needRefresh {
ProgressView()
.scaleEffect(1.75)
.onAppear {
Haptic.shared.play(.light)
}
} else {
Text("")
}
Spacer()
}
}.padding(.top, -50)
}
}
-18
View File
@@ -13,11 +13,6 @@ struct SettingsView: View {
@AppStorage("cpHash") var cpHash: String = "" // CarPlay hash
@AppStorage("ignoreDurationLimit") var ignoreDurationLimit: Bool = false
// DEBUG
#if DEBUG
@AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink
#endif
@State var checkingForHash: Bool = false
@State var hashCheckTask: Task<Void, any Error>? = nil
@@ -58,19 +53,6 @@ struct SettingsView: View {
Label("App Hash", systemImage: "lock.app.dashed")
}
#if DEBUG
Section {
VStack {
Text(String("Explore Page URL"))
TextField(String("Enter Explore Page URL"), text: $exploreLink)
.textFieldStyle(RoundedBorderTextFieldStyle())
.font(.system(.body, design: .monospaced))
}
} header: {
Label(String("Debug"), systemImage: "ant")
}
#endif
Section {
Toggle(isOn: $ignoreDurationLimit, label: {
Label("Disable Video Duration Limit", systemImage: "ruler")