Add iPhone local library search
This commit is contained in:
parent
18ed79e3c4
commit
cb556ccf0a
@ -38,77 +38,90 @@ struct iPhoneLibraryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Remote Library: \(viewModel.remoteTracks.count)") {
|
Section("Remote Library: \(viewModel.remoteTracks.count)") {
|
||||||
ForEach(viewModel.remoteTracks) { track in
|
if viewModel.remoteTracks.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
if viewModel.hasActiveSearch && viewModel.hasCachedRemoteTracks {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
Text("No matching tracks found.")
|
||||||
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(track.title)
|
|
||||||
.font(.headline)
|
|
||||||
Text(track.artist)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text(track.statusText)
|
|
||||||
.font(.caption.weight(.semibold))
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(statusColor(for: track.status), in: Capsule())
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text("Duration: \(track.durationText)")
|
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text("Remote track ID: \(track.remoteTrackID)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
Text("Asset ID: \(track.assetID)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Button(track.downloadButtonTitle) {
|
|
||||||
Task {
|
|
||||||
await viewModel.downloadTrack(trackID: track.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(!track.canDownload)
|
|
||||||
|
|
||||||
if track.canPlay {
|
|
||||||
Button(track.playButtonTitle) {
|
|
||||||
viewModel.togglePlayback(trackID: track.id)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = track.lastDownloadError,
|
|
||||||
(track.status == .failed || track.status == .missing)
|
|
||||||
{
|
|
||||||
Text(error)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
} else {
|
||||||
|
ForEach(viewModel.remoteTracks) { track in
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(track.title)
|
||||||
|
.font(.headline)
|
||||||
|
Text(track.artist)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(track.statusText)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(statusColor(for: track.status), in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Duration: \(track.durationText)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Remote track ID: \(track.remoteTrackID)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
Text("Asset ID: \(track.assetID)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button(track.downloadButtonTitle) {
|
||||||
|
Task {
|
||||||
|
await viewModel.downloadTrack(trackID: track.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(!track.canDownload)
|
||||||
|
|
||||||
|
if track.canPlay {
|
||||||
|
Button(track.playButtonTitle) {
|
||||||
|
viewModel.togglePlayback(trackID: track.id)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = track.lastDownloadError,
|
||||||
|
(track.status == .failed || track.status == .missing)
|
||||||
|
{
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Available Offline: \(viewModel.availableOfflineTracks.count)") {
|
Section("Available Offline: \(viewModel.availableOfflineTracks.count)") {
|
||||||
if viewModel.availableOfflineTracks.isEmpty {
|
if viewModel.availableOfflineTracks.isEmpty {
|
||||||
Text("Downloaded tracks with a verified local MP3 will appear here.")
|
Text(
|
||||||
.font(.subheadline)
|
viewModel.hasActiveSearch
|
||||||
.foregroundStyle(.secondary)
|
? "No matching tracks found."
|
||||||
|
: "Downloaded tracks with a verified local MP3 will appear here."
|
||||||
|
)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(viewModel.availableOfflineTracks) { track in
|
ForEach(viewModel.availableOfflineTracks) { track in
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@ -154,6 +167,15 @@ struct iPhoneLibraryView: View {
|
|||||||
.overlay {
|
.overlay {
|
||||||
overlayView
|
overlayView
|
||||||
}
|
}
|
||||||
|
.searchable(
|
||||||
|
text: Binding(
|
||||||
|
get: { viewModel.searchText },
|
||||||
|
set: { viewModel.searchText = $0 }
|
||||||
|
),
|
||||||
|
placement: .navigationBarDrawer(displayMode: .always),
|
||||||
|
prompt: "Search Library"
|
||||||
|
)
|
||||||
|
.autocorrectionDisabled()
|
||||||
.navigationTitle("Velody")
|
.navigationTitle("Velody")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
@ -191,7 +213,7 @@ struct iPhoneLibraryView: View {
|
|||||||
private var overlayView: some View {
|
private var overlayView: some View {
|
||||||
switch viewModel.state {
|
switch viewModel.state {
|
||||||
case .idle:
|
case .idle:
|
||||||
if viewModel.remoteTracks.isEmpty {
|
if !viewModel.hasCachedRemoteTracks {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Remote Library Yet",
|
"No Remote Library Yet",
|
||||||
systemImage: "music.note.list",
|
systemImage: "music.note.list",
|
||||||
@ -209,7 +231,7 @@ struct iPhoneLibraryView: View {
|
|||||||
description: Text("The backend returned no remote tracks for this iPhone.")
|
description: Text("The backend returned no remote tracks for this iPhone.")
|
||||||
)
|
)
|
||||||
case .networkError(let message):
|
case .networkError(let message):
|
||||||
if viewModel.remoteTracks.isEmpty {
|
if !viewModel.hasCachedRemoteTracks {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Network Error",
|
"Network Error",
|
||||||
systemImage: "wifi.exclamationmark",
|
systemImage: "wifi.exclamationmark",
|
||||||
|
|||||||
@ -176,6 +176,15 @@ final class iPhoneLibraryViewModel {
|
|||||||
|
|
||||||
var remoteTracks: [RemoteTrackRowViewData] = []
|
var remoteTracks: [RemoteTrackRowViewData] = []
|
||||||
var availableOfflineTracks: [AvailableOfflineTrackRowViewData] = []
|
var availableOfflineTracks: [AvailableOfflineTrackRowViewData] = []
|
||||||
|
var searchText = "" {
|
||||||
|
didSet {
|
||||||
|
guard searchText != oldValue else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
var syncStatus = "Remote library not synced yet."
|
var syncStatus = "Remote library not synced yet."
|
||||||
var state: ViewState = .idle
|
var state: ViewState = .idle
|
||||||
var nowPlaying = iPhoneNowPlayingState(
|
var nowPlaying = iPhoneNowPlayingState(
|
||||||
@ -197,6 +206,14 @@ final class iPhoneLibraryViewModel {
|
|||||||
private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = []
|
private var cachedAvailableOfflineTracks: [OfflineLibraryTrack] = []
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
|
|
||||||
|
var hasActiveSearch: Bool {
|
||||||
|
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasCachedRemoteTracks: Bool {
|
||||||
|
!cachedRemoteLibraryTracks.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
player: (any iPhoneLocalAudioPlaying)? = nil,
|
player: (any iPhoneLocalAudioPlaying)? = nil,
|
||||||
keychainService: any KeychainService = SystemKeychainService(
|
keychainService: any KeychainService = SystemKeychainService(
|
||||||
@ -427,13 +444,18 @@ final class iPhoneLibraryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func rebuildRows() {
|
private func rebuildRows() {
|
||||||
remoteTracks = cachedRemoteLibraryTracks.map { track in
|
let filteredSnapshot = OfflineLibrarySnapshot(
|
||||||
|
remoteTracks: cachedRemoteLibraryTracks,
|
||||||
|
availableTracks: cachedAvailableOfflineTracks
|
||||||
|
).filtered(searchText: searchText)
|
||||||
|
|
||||||
|
remoteTracks = filteredSnapshot.remoteTracks.map { track in
|
||||||
RemoteTrackRowViewData(
|
RemoteTrackRowViewData(
|
||||||
track: track,
|
track: track,
|
||||||
nowPlaying: nowPlaying
|
nowPlaying: nowPlaying
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
availableOfflineTracks = cachedAvailableOfflineTracks.map { track in
|
availableOfflineTracks = filteredSnapshot.availableTracks.map { track in
|
||||||
AvailableOfflineTrackRowViewData(
|
AvailableOfflineTrackRowViewData(
|
||||||
track: track,
|
track: track,
|
||||||
nowPlaying: nowPlaying
|
nowPlaying: nowPlaying
|
||||||
|
|||||||
@ -17,5 +17,9 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "VelodyDomain"
|
name: "VelodyDomain"
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "VelodyDomainTests",
|
||||||
|
dependencies: ["VelodyDomain"]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension OfflineLibrarySnapshot {
|
||||||
|
func filtered(searchText: String) -> OfflineLibrarySnapshot {
|
||||||
|
guard let normalizedSearchText = OfflineLibrarySearch.normalizedQuery(from: searchText) else {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
return OfflineLibrarySnapshot(
|
||||||
|
remoteTracks: remoteTracks.filter { $0.matchesSearchQuery(normalizedSearchText) },
|
||||||
|
availableTracks: availableTracks.filter { $0.matchesSearchQuery(normalizedSearchText) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum OfflineLibrarySearch {
|
||||||
|
static func normalizedQuery(from searchText: String) -> String? {
|
||||||
|
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedQuery.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalize(trimmedQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalize(_ value: String) -> String {
|
||||||
|
value
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension OfflineLibraryRemoteTrack {
|
||||||
|
func matchesSearchQuery(_ query: String) -> Bool {
|
||||||
|
OfflineLibrarySearch.normalize(remoteTrack.title).contains(query)
|
||||||
|
|| OfflineLibrarySearch.normalize(remoteTrack.artist).contains(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension OfflineLibraryTrack {
|
||||||
|
func matchesSearchQuery(_ query: String) -> Bool {
|
||||||
|
OfflineLibrarySearch.normalize(title).contains(query)
|
||||||
|
|| OfflineLibrarySearch.normalize(artist).contains(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import VelodyDomain
|
||||||
|
|
||||||
|
final class OfflineLibrarySearchTests: XCTestCase {
|
||||||
|
func testSearchByTitleFiltersMatchingTracks() {
|
||||||
|
let snapshot = makeSnapshot()
|
||||||
|
|
||||||
|
let filtered = snapshot.filtered(searchText: "trap")
|
||||||
|
|
||||||
|
XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-light-trap"])
|
||||||
|
XCTAssertEqual(filtered.availableTracks.map(\.remoteTrackId), ["remote-light-trap"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchByArtistFiltersMatchingTracks() {
|
||||||
|
let snapshot = makeSnapshot()
|
||||||
|
|
||||||
|
let filtered = snapshot.filtered(searchText: "unknown")
|
||||||
|
|
||||||
|
XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-light-trap"])
|
||||||
|
XCTAssertEqual(filtered.availableTracks.map(\.remoteTrackId), ["remote-light-trap"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSearchIsCaseInsensitiveAndTrimsWhitespace() {
|
||||||
|
let snapshot = makeSnapshot()
|
||||||
|
|
||||||
|
let filtered = snapshot.filtered(searchText: " TRAP ")
|
||||||
|
|
||||||
|
XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-light-trap"])
|
||||||
|
XCTAssertEqual(filtered.availableTracks.map(\.remoteTrackId), ["remote-light-trap"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptySearchResultReturnsEmptySections() {
|
||||||
|
let snapshot = makeSnapshot()
|
||||||
|
|
||||||
|
let filtered = snapshot.filtered(searchText: "nope")
|
||||||
|
|
||||||
|
XCTAssertTrue(filtered.remoteTracks.isEmpty)
|
||||||
|
XCTAssertTrue(filtered.availableTracks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearingSearchRestoresFullList() {
|
||||||
|
let snapshot = makeSnapshot()
|
||||||
|
|
||||||
|
let filtered = snapshot.filtered(searchText: "")
|
||||||
|
|
||||||
|
XCTAssertEqual(filtered.remoteTracks, snapshot.remoteTracks)
|
||||||
|
XCTAssertEqual(filtered.availableTracks, snapshot.availableTracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOfflineSectionFilteringUsesAvailableOfflineTracks() {
|
||||||
|
let snapshot = makeSnapshot()
|
||||||
|
|
||||||
|
let filtered = snapshot.filtered(searchText: "harbor")
|
||||||
|
|
||||||
|
XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-harbor-lights"])
|
||||||
|
XCTAssertEqual(filtered.availableTracks.map(\.remoteTrackId), ["remote-harbor-lights"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoteSectionFilteringUsesRemoteLibraryTracks() {
|
||||||
|
let snapshot = makeSnapshot()
|
||||||
|
|
||||||
|
let filtered = snapshot.filtered(searchText: "shadow")
|
||||||
|
|
||||||
|
XCTAssertEqual(filtered.remoteTracks.map(\.remoteTrack.trackId), ["remote-shadow-city"])
|
||||||
|
XCTAssertTrue(filtered.availableTracks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFilteringDoesNotMutateUnderlyingSnapshotData() {
|
||||||
|
let snapshot = makeSnapshot()
|
||||||
|
let originalRemoteTracks = snapshot.remoteTracks
|
||||||
|
let originalAvailableTracks = snapshot.availableTracks
|
||||||
|
|
||||||
|
_ = snapshot.filtered(searchText: "light")
|
||||||
|
|
||||||
|
XCTAssertEqual(snapshot.remoteTracks, originalRemoteTracks)
|
||||||
|
XCTAssertEqual(snapshot.availableTracks, originalAvailableTracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSnapshot() -> OfflineLibrarySnapshot {
|
||||||
|
let remoteTracks = [
|
||||||
|
makeRemoteLibraryTrack(
|
||||||
|
trackId: "remote-light-trap",
|
||||||
|
assetId: "asset-light-trap",
|
||||||
|
title: "Light Trap",
|
||||||
|
artist: "Unknown Artist",
|
||||||
|
isFileAvailable: true
|
||||||
|
),
|
||||||
|
makeRemoteLibraryTrack(
|
||||||
|
trackId: "remote-shadow-city",
|
||||||
|
assetId: "asset-shadow-city",
|
||||||
|
title: "Shadow City",
|
||||||
|
artist: "Night Runner",
|
||||||
|
isFileAvailable: false
|
||||||
|
),
|
||||||
|
makeRemoteLibraryTrack(
|
||||||
|
trackId: "remote-harbor-lights",
|
||||||
|
assetId: "asset-harbor-lights",
|
||||||
|
title: "Harbor Lights",
|
||||||
|
artist: "The Northline",
|
||||||
|
isFileAvailable: true
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
let availableTracks = [
|
||||||
|
makeOfflineTrack(
|
||||||
|
trackId: "remote-light-trap",
|
||||||
|
assetId: "asset-light-trap",
|
||||||
|
title: "Light Trap",
|
||||||
|
artist: "Unknown Artist"
|
||||||
|
),
|
||||||
|
makeOfflineTrack(
|
||||||
|
trackId: "remote-harbor-lights",
|
||||||
|
assetId: "asset-harbor-lights",
|
||||||
|
title: "Harbor Lights",
|
||||||
|
artist: "The Northline"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return OfflineLibrarySnapshot(
|
||||||
|
remoteTracks: remoteTracks,
|
||||||
|
availableTracks: availableTracks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRemoteLibraryTrack(
|
||||||
|
trackId: String,
|
||||||
|
assetId: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
isFileAvailable: Bool
|
||||||
|
) -> OfflineLibraryRemoteTrack {
|
||||||
|
OfflineLibraryRemoteTrack(
|
||||||
|
remoteTrack: RemoteTrack(
|
||||||
|
trackId: trackId,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
durationSeconds: 180,
|
||||||
|
sha256: String(repeating: "a", count: 64),
|
||||||
|
assetId: assetId,
|
||||||
|
createdAt: "2026-05-30T10:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-30T10:00:00.000Z"
|
||||||
|
),
|
||||||
|
localFilePath: isFileAvailable ? "/tmp/\(assetId).mp3" : "",
|
||||||
|
downloadedAt: isFileAvailable ? Date(timeIntervalSince1970: 1_000) : nil,
|
||||||
|
isFileAvailable: isFileAvailable,
|
||||||
|
status: isFileAvailable ? .downloaded : .notDownloaded,
|
||||||
|
lastDownloadError: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeOfflineTrack(
|
||||||
|
trackId: String,
|
||||||
|
assetId: String,
|
||||||
|
title: String,
|
||||||
|
artist: String
|
||||||
|
) -> OfflineLibraryTrack {
|
||||||
|
OfflineLibraryTrack(
|
||||||
|
remoteTrackId: trackId,
|
||||||
|
assetId: assetId,
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
durationSeconds: 180,
|
||||||
|
sha256: String(repeating: "b", count: 64),
|
||||||
|
localFilePath: "/tmp/\(assetId).mp3",
|
||||||
|
downloadedAt: Date(timeIntervalSince1970: 1_000),
|
||||||
|
isFileAvailable: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user