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 */ /* Begin PBXBuildFile section */
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; }; 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; };
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; }; 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; };
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -31,6 +32,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */,
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */, 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */,
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */, 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
); );
@@ -77,6 +79,7 @@
packageProductDependencies = ( packageProductDependencies = (
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */, 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
6F09BA322E00808300CDE89C /* Dynamic */, 6F09BA322E00808300CDE89C /* Dynamic */,
6FB105542E26D3EC00185A5C /* CachedAsyncImage */,
); );
productName = "Pocket Poster"; productName = "Pocket Poster";
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */; productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
@@ -109,6 +112,7 @@
packageReferences = ( packageReferences = (
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */, 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */,
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */; productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
@@ -366,6 +370,14 @@
minimumVersion = 1.2.0; 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 */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@@ -379,6 +391,11 @@
package = 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */; package = 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */;
productName = Dynamic; productName = Dynamic;
}; };
6FB105542E26D3EC00185A5C /* CachedAsyncImage */ = {
isa = XCSwiftPackageProductDependency;
package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
productName = CachedAsyncImage;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */; rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
@@ -1,5 +1,5 @@
{ {
"originHash" : "b781b75a76813cee1292db0b540a9eecd3df93961ced8a0063095503735dadb2", "originHash" : "73a600cf677d3bb2b929c60349923a2a4e20e6a9ef10509bf043d2ad2229ca95",
"pins" : [ "pins" : [
{ {
"identity" : "dynamic", "identity" : "dynamic",
@@ -10,6 +10,15 @@
"version" : "1.2.0" "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", "identity" : "zipfoundation",
"kind" : "remoteSourceControl", "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", "sourceLanguage" : "en",
"strings" : { "strings" : {
"" : {
},
"Actions" : { "Actions" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@@ -462,6 +465,9 @@
} }
} }
} }
},
"Apple" : {
}, },
"Apply" : { "Apply" : {
"localizations" : { "localizations" : {
@@ -1438,6 +1444,9 @@
} }
} }
} }
},
"Could not connect to server" : {
}, },
"Could not download wallpaper!" : { "Could not download wallpaper!" : {
"localizations" : { "localizations" : {
@@ -2210,6 +2219,9 @@
} }
} }
} }
},
"Custom" : {
}, },
"Detect" : { "Detect" : {
"localizations" : { "localizations" : {
@@ -4229,6 +4241,9 @@
}, },
"Failed to fetch CarPlay wallpapers" : { "Failed to fetch CarPlay wallpapers" : {
},
"Failed to fetch wallpapers" : {
}, },
"Failed to import tendies" : { "Failed to import tendies" : {
"localizations" : { "localizations" : {
@@ -4537,9 +4552,6 @@
} }
} }
} }
},
"Failed to load explore page." : {
}, },
"Fallback Shortcut Creator" : { "Fallback Shortcut Creator" : {
"localizations" : { "localizations" : {
@@ -4848,6 +4860,9 @@
} }
} }
} }
},
"Filter Wallpapers" : {
}, },
"Find Wallpapers" : { "Find Wallpapers" : {
"localizations" : { "localizations" : {
@@ -8749,9 +8764,6 @@
} }
} }
} }
},
"Retry" : {
}, },
"Select Tendies" : { "Select Tendies" : {
"localizations" : { "localizations" : {
@@ -10603,6 +10615,9 @@
} }
} }
} }
},
"Unable to obtain repo hash. Maybe update to the latest version?" : {
}, },
"Update Available" : { "Update Available" : {
"comment" : "app update available on GitHub", "comment" : "app update available on GitHub",
@@ -6,41 +6,209 @@
// //
import SwiftUI import SwiftUI
import CachedAsyncImage
let MIN_SIZE: CGFloat = 165
struct ExploreView: View { struct ExploreView: View {
@State private var isLoading = true @ObservedObject var cowabungaAPI = CowabungaAPI.shared
@State private var error: Error? = nil
#if DEBUG // lazyvgrid
@AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink @State private var gridItemLayout = [GridItem(.adaptive(minimum: MIN_SIZE))]
#endif @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 { var body: some View {
ZStack { NavigationView {
#if DEBUG ScrollView {
let websiteURL = URL(string: exploreLink) PullToRefresh(coordinateSpaceName: "pullToRefresh") {
#else // refresh
let websiteURL = URL(string: DownloadManager.exploreLink) wallpapers.removeAll()
#endif Haptic.shared.play(.light)
if let error = error { //URLCache.imageCache.removeAllCachedResponses()
loadWallpapers()
}
VStack { VStack {
Text(error.localizedDescription) Picker("", selection: $wallpaperTypeSelected) {
.foregroundStyle(.red) Text("Custom").tag(0)
Text("Apple").tag(1)
}
.pickerStyle(.segmented)
}
.padding(.horizontal, 24)
.padding(.bottom, 10)
if wallpapers.isEmpty {
ProgressView()
.scaleEffect(1.75)
.navigationTitle("Explore")
} else {
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: { Button(action: {
isLoading = true showFilterChangerPopup()
self.error = nil
}) { }) {
Text("Retry") Image(systemName: "line.3.horizontal.decrease.circle")
} }
} }
} else if let url = websiteURL { }
WallpaperWebView(url, isLoading: $isLoading, error: $error) }
if isLoading { .onAppear {
ProgressView() loadWallpapers()
} }
} else { .onChange(of: wallpaperTypeSelected) { newValue in
Text("Failed to load explore page.") 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("cpHash") var cpHash: String = "" // CarPlay hash
@AppStorage("ignoreDurationLimit") var ignoreDurationLimit: Bool = false @AppStorage("ignoreDurationLimit") var ignoreDurationLimit: Bool = false
// DEBUG
#if DEBUG
@AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink
#endif
@State var checkingForHash: Bool = false @State var checkingForHash: Bool = false
@State var hashCheckTask: Task<Void, any Error>? = nil @State var hashCheckTask: Task<Void, any Error>? = nil
@@ -58,19 +53,6 @@ struct SettingsView: View {
Label("App Hash", systemImage: "lock.app.dashed") 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 { Section {
Toggle(isOn: $ignoreDurationLimit, label: { Toggle(isOn: $ignoreDurationLimit, label: {
Label("Disable Video Duration Limit", systemImage: "ruler") Label("Disable Video Duration Limit", systemImage: "ruler")