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