Initalier Commit
This commit is contained in:
31
Linkster/ApiClient.swift
Normal file
31
Linkster/ApiClient.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
struct ApiClient {
|
||||
static let shared = ApiClient()
|
||||
|
||||
private let base = URL(string: "https://linkster.langhei.de/api")!
|
||||
|
||||
func resolve(url: String) async throws -> SongResult? {
|
||||
var components = URLComponents(url: base.appendingPathComponent("resolve"), resolvingAgainstBaseURL: false)!
|
||||
components.queryItems = [URLQueryItem(name: "url", value: url)]
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: components.url!)
|
||||
let json = try JSONDecoder().decode(ResolveResponse.self, from: data)
|
||||
guard json.found else { return nil }
|
||||
|
||||
return SongResult(
|
||||
title: json.title ?? "",
|
||||
artist: json.artist ?? "",
|
||||
playlistName: json.playlist ?? "",
|
||||
providers: json.providers ?? [:]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ResolveResponse: Decodable {
|
||||
let found: Bool
|
||||
let title: String?
|
||||
let artist: String?
|
||||
let playlist: String?
|
||||
let providers: [String: String]?
|
||||
}
|
||||
46
Linkster/ContentView.swift
Normal file
46
Linkster/ContentView.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@AppStorage("selectedService") private var selectedServiceRaw = StreamingService.deezer.rawValue
|
||||
|
||||
private var selectedService: Binding<StreamingService> {
|
||||
Binding(
|
||||
get: { StreamingService(rawValue: selectedServiceRaw) ?? .deezer },
|
||||
set: { selectedServiceRaw = $0.rawValue }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color("Background").ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 40) {
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Picker("Streaming-Dienst", selection: selectedService) {
|
||||
ForEach(StreamingService.allCases) { service in
|
||||
Text(service.displayName).tag(service)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.tint(Color("Primary"))
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
NavigationLink(destination: GameView(service: selectedService.wrappedValue)) {
|
||||
Text("Starten")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 220, height: 56)
|
||||
.background(Color("Primary"))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
Linkster/GameView.swift
Normal file
95
Linkster/GameView.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GameView: View {
|
||||
let service: StreamingService
|
||||
|
||||
@State private var isScanning = false
|
||||
@State private var isLoading = false
|
||||
@State private var alert: AlertInfo?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color("Background").ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 48) {
|
||||
Image("AppLogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 160, height: 160)
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(Color("Primary"))
|
||||
.scaleEffect(1.4)
|
||||
} else {
|
||||
Button(action: { isScanning = true }) {
|
||||
Text("Nächste Karte")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 220, height: 56)
|
||||
.background(Color("Primary"))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $isScanning) {
|
||||
QRScannerView { scannedUrl in
|
||||
isScanning = false
|
||||
handleScanned(url: scannedUrl)
|
||||
}
|
||||
}
|
||||
.alert(item: $alert) { info in
|
||||
Alert(
|
||||
title: Text(info.title),
|
||||
message: Text(info.message),
|
||||
dismissButton: info.scanNext
|
||||
? .default(Text("Nächste Karte")) { isScanning = true }
|
||||
: .default(Text("OK"))
|
||||
)
|
||||
}
|
||||
.onAppear { isScanning = true }
|
||||
}
|
||||
|
||||
private func handleScanned(url: String) {
|
||||
isLoading = true
|
||||
Task {
|
||||
do {
|
||||
let song = try await ApiClient.shared.resolve(url: url)
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
guard let song else {
|
||||
alert = AlertInfo(
|
||||
title: "Nicht gefunden",
|
||||
message: "Dieser QR-Code ist keiner bekannten Hitster-Karte zugeordnet.",
|
||||
scanNext: false
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let serviceUrl = song.url(for: service) else {
|
||||
alert = AlertInfo(
|
||||
title: "Nicht verfügbar",
|
||||
message: "\(song.artist) – \(song.title)\n\nist auf \(service.displayName) nicht verfügbar.\n\nBitte ziehe die nächste Karte.",
|
||||
scanNext: true
|
||||
)
|
||||
return
|
||||
}
|
||||
UIApplication.shared.open(serviceUrl)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
alert = AlertInfo(title: "Fehler", message: error.localizedDescription, scanNext: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AlertInfo: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let message: String
|
||||
let scanNext: Bool
|
||||
}
|
||||
18
Linkster/Info.plist
Normal file
18
Linkster/Info.plist
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Die Kamera wird benötigt, um QR-Codes auf Hitster-Karten zu scannen.</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>spotify</string>
|
||||
<string>deezer</string>
|
||||
<string>music</string>
|
||||
<string>youtubemusic</string>
|
||||
<string>youtube</string>
|
||||
<string>tidal</string>
|
||||
<string>soundcloud</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
10
Linkster/LinksterApp.swift
Normal file
10
Linkster/LinksterApp.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct LinksterApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
62
Linkster/QRScannerView.swift
Normal file
62
Linkster/QRScannerView.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onScan: (String) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let vc = ScannerViewController()
|
||||
vc.onScan = onScan
|
||||
return vc
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onScan: ((String) -> Void)?
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setupCapture()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.bounds
|
||||
}
|
||||
|
||||
private func setupCapture() {
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else { return }
|
||||
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else { return }
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||
layer.videoGravity = .resizeAspectFill
|
||||
view.layer.addSublayer(layer)
|
||||
previewLayer = layer
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
||||
didOutput objects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection) {
|
||||
guard let obj = objects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let value = obj.stringValue else { return }
|
||||
session.stopRunning()
|
||||
onScan?(value)
|
||||
}
|
||||
}
|
||||
12
Linkster/SongResult.swift
Normal file
12
Linkster/SongResult.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct SongResult {
|
||||
let title: String
|
||||
let artist: String
|
||||
let playlistName: String
|
||||
let providers: [String: String]
|
||||
|
||||
func url(for service: StreamingService) -> URL? {
|
||||
providers[service.rawValue].flatMap { URL(string: $0) }
|
||||
}
|
||||
}
|
||||
27
Linkster/StreamingService.swift
Normal file
27
Linkster/StreamingService.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
enum StreamingService: String, CaseIterable, Identifiable {
|
||||
case deezer = "deezer"
|
||||
case spotify = "spotify"
|
||||
case appleMusic = "appleMusic"
|
||||
case amazonMusic = "amazonMusic"
|
||||
case tidal = "tidal"
|
||||
case soundcloud = "soundcloud"
|
||||
case youtube = "youtube"
|
||||
case youtubeMusic = "youtubeMusic"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .deezer: return "Deezer"
|
||||
case .spotify: return "Spotify"
|
||||
case .appleMusic: return "Apple Music"
|
||||
case .amazonMusic: return "Amazon Music"
|
||||
case .tidal: return "Tidal"
|
||||
case .soundcloud: return "SoundCloud"
|
||||
case .youtube: return "YouTube"
|
||||
case .youtubeMusic: return "YouTube Music"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
README.md
Normal file
17
README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Linkster iOS
|
||||
|
||||
## Xcode-Setup
|
||||
|
||||
1. Xcode öffnen → *Create a new Xcode project*
|
||||
2. **App** wählen, Product Name: `Linkster`, Team auswählen, Bundle ID z.B. `de.oaa.linkster`
|
||||
3. Alle `.swift`-Dateien und `Info.plist` aus diesem Ordner in das Projekt ziehen (Replace existing Info.plist)
|
||||
4. In **Assets.xcassets** zwei Einträge anlegen:
|
||||
- `AppLogo` — das App-Icon als Image Set
|
||||
- `Primary` — Akzentfarbe (Color Set)
|
||||
- `Background` — Hintergrundfarbe (Color Set)
|
||||
|
||||
## Berechtigungen
|
||||
|
||||
Die `Info.plist` enthält bereits:
|
||||
- `NSCameraUsageDescription` — für den QR-Scanner
|
||||
- `LSApplicationQueriesSchemes` — damit iOS prüfen kann, ob Streaming-Apps installiert sind
|
||||
BIN
linkster_logo.png
Normal file
BIN
linkster_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
Reference in New Issue
Block a user