Initalier Commit

This commit is contained in:
2026-04-19 19:55:22 +02:00
commit 93e2c811b6
10 changed files with 318 additions and 0 deletions

31
Linkster/ApiClient.swift Normal file
View 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]?
}

View 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
View 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
View 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>

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct LinksterApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View 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
View 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) }
}
}

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB