commit 93e2c811b647823aa6c020e380cb3d3e670e2403 Author: Mario Date: Sun Apr 19 19:55:22 2026 +0200 Initalier Commit diff --git a/Linkster/ApiClient.swift b/Linkster/ApiClient.swift new file mode 100644 index 0000000..8abfa71 --- /dev/null +++ b/Linkster/ApiClient.swift @@ -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]? +} diff --git a/Linkster/ContentView.swift b/Linkster/ContentView.swift new file mode 100644 index 0000000..b62758f --- /dev/null +++ b/Linkster/ContentView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct ContentView: View { + @AppStorage("selectedService") private var selectedServiceRaw = StreamingService.deezer.rawValue + + private var selectedService: Binding { + 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) + } + } +} diff --git a/Linkster/GameView.swift b/Linkster/GameView.swift new file mode 100644 index 0000000..4196ef6 --- /dev/null +++ b/Linkster/GameView.swift @@ -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 +} diff --git a/Linkster/Info.plist b/Linkster/Info.plist new file mode 100644 index 0000000..f4e8bcd --- /dev/null +++ b/Linkster/Info.plist @@ -0,0 +1,18 @@ + + + + + NSCameraUsageDescription + Die Kamera wird benötigt, um QR-Codes auf Hitster-Karten zu scannen. + LSApplicationQueriesSchemes + + spotify + deezer + music + youtubemusic + youtube + tidal + soundcloud + + + diff --git a/Linkster/LinksterApp.swift b/Linkster/LinksterApp.swift new file mode 100644 index 0000000..c1d64b4 --- /dev/null +++ b/Linkster/LinksterApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct LinksterApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Linkster/QRScannerView.swift b/Linkster/QRScannerView.swift new file mode 100644 index 0000000..ac73840 --- /dev/null +++ b/Linkster/QRScannerView.swift @@ -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) + } +} diff --git a/Linkster/SongResult.swift b/Linkster/SongResult.swift new file mode 100644 index 0000000..6b95e97 --- /dev/null +++ b/Linkster/SongResult.swift @@ -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) } + } +} diff --git a/Linkster/StreamingService.swift b/Linkster/StreamingService.swift new file mode 100644 index 0000000..c4571ad --- /dev/null +++ b/Linkster/StreamingService.swift @@ -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" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..58b27f7 --- /dev/null +++ b/README.md @@ -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 diff --git a/linkster_logo.png b/linkster_logo.png new file mode 100644 index 0000000..d212d5c Binary files /dev/null and b/linkster_logo.png differ