Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75,356 changes: 37,879 additions & 37,477 deletions Sources/Kaset/Resources/Localizable.xcstrings

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "ابدأ محادثة جديدة للمتابعة.";
"Ask Gemini response ready" = "رد «اسأل Gemini» جاهز";
"New Ask Gemini chat ready" = "محادثة «اسأل Gemini» الجديدة جاهزة";
"View Artwork" = "عرض الأعمال الفنية";
"Album Artwork" = "أعمال الألبوم الفنية";
"Artwork Unavailable" = "العمل الفني غير متوفر";
"This track has no artwork available." = "لا يتوفر عمل فني لهذا المقطع.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Starte einen neuen Chat, um fortzufahren.";
"Ask Gemini response ready" = "Antwort von „Gemini fragen“ ist bereit";
"New Ask Gemini chat ready" = "Neuer Chat mit „Gemini fragen“ ist bereit";
"View Artwork" = "Artwork anzeigen";
"Album Artwork" = "Album-Artwork";
"Artwork Unavailable" = "Artwork nicht verfügbar";
"This track has no artwork available." = "Für diesen Titel ist kein Artwork verfügbar.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@
"Start a new chat to continue." = "Start a new chat to continue.";
"Ask Gemini response ready" = "Ask Gemini response ready";
"New Ask Gemini chat ready" = "New Ask Gemini chat ready";
"View Artwork" = "View Artwork";
"Album Artwork" = "Album Artwork";
"Artwork Unavailable" = "Artwork Unavailable";
"This track has no artwork available." = "This track has no artwork available.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Inicia una nueva conversación para continuar.";
"Ask Gemini response ready" = "Respuesta de Preguntar a Gemini lista";
"New Ask Gemini chat ready" = "Nueva conversación de Preguntar a Gemini lista";
"View Artwork" = "Ver arte";
"Album Artwork" = "Arte del álbum";
"Artwork Unavailable" = "Arte no disponible";
"This track has no artwork available." = "Esta pista no tiene arte disponible.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Commencez une nouvelle conversation pour continuer.";
"Ask Gemini response ready" = "Réponse de Demander à Gemini prête";
"New Ask Gemini chat ready" = "La nouvelle conversation « Demander à Gemini » est prête";
"View Artwork" = "Voir l'art";
"Album Artwork" = "Pochette de l'album";
"Artwork Unavailable" = "Pochette indisponible";
"This track has no artwork available." = "Aucune pochette n'est disponible pour ce titre.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/id.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Mulai chat baru untuk melanjutkan.";
"Ask Gemini response ready" = "Respons Tanya Gemini siap";
"New Ask Gemini chat ready" = "Chat Tanya Gemini baru siap";
"View Artwork" = "Lihat Karya Seni";
"Album Artwork" = "Karya Seni Album";
"Artwork Unavailable" = "Karya Seni Tidak Tersedia";
"This track has no artwork available." = "Lagu ini tidak memiliki karya seni yang tersedia.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Avvia una nuova chat per continuare.";
"Ask Gemini response ready" = "Risposta di Chiedi a Gemini pronta";
"New Ask Gemini chat ready" = "Nuova chat di Chiedi a Gemini pronta";
"View Artwork" = "Mostra copertina";
"Album Artwork" = "Copertina album";
"Artwork Unavailable" = "Copertina non disponibile";
"This track has no artwork available." = "Questo brano non ha una copertina disponibile.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "계속하려면 새 채팅을 시작하세요.";
"Ask Gemini response ready" = "Gemini에게 질문 응답 준비됨";
"New Ask Gemini chat ready" = "새 Gemini 채팅이 준비되었습니다";
"View Artwork" = "아트워크 보기";
"Album Artwork" = "앨범 아트워크";
"Artwork Unavailable" = "아트워크를 사용할 수 없음";
"This track has no artwork available." = "이 트랙에는 사용할 수 있는 아트워크가 없습니다.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/nl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Start een nieuwe chat om door te gaan.";
"Ask Gemini response ready" = "Antwoord van Vraag Gemini is klaar";
"New Ask Gemini chat ready" = "Nieuwe chat met Vraag Gemini is klaar";
"View Artwork" = "Artwork bekijken";
"Album Artwork" = "Album-artwork";
"Artwork Unavailable" = "Artwork niet beschikbaar";
"This track has no artwork available." = "Voor dit nummer is geen artwork beschikbaar.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/pl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Rozpocznij nowy czat, aby kontynuować.";
"Ask Gemini response ready" = "Odpowiedź Zapytaj Gemini jest gotowa";
"New Ask Gemini chat ready" = "Nowy czat Zapytaj Gemini jest gotowy";
"View Artwork" = "Pokaż okładkę";
"Album Artwork" = "Okładka albumu";
"Artwork Unavailable" = "Okładka niedostępna";
"This track has no artwork available." = "Ten utwór nie ma dostępnej okładki.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/pt.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Inicie uma nova conversa para continuar.";
"Ask Gemini response ready" = "Resposta do Perguntar ao Gemini pronta";
"New Ask Gemini chat ready" = "Nova conversa com o Gemini pronta";
"View Artwork" = "Ver arte";
"Album Artwork" = "Arte do álbum";
"Artwork Unavailable" = "Arte indisponível";
"This track has no artwork available." = "Esta faixa não tem arte disponível.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/ru.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Начните новый чат, чтобы продолжить.";
"Ask Gemini response ready" = "Ответ «Спросить Gemini» готов";
"New Ask Gemini chat ready" = "Новый чат «Спросить Gemini» готов";
"View Artwork" = "Просмотреть обложку";
"Album Artwork" = "Обложка альбома";
"Artwork Unavailable" = "Обложка недоступна";
"This track has no artwork available." = "Для этого трека нет обложки.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/sv.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Starta en ny chatt för att fortsätta.";
"Ask Gemini response ready" = "Svaret från Fråga Gemini är klart";
"New Ask Gemini chat ready" = "Ny Fråga Gemini-chatt är klar";
"View Artwork" = "Visa omslag";
"Album Artwork" = "Albumomslag";
"Artwork Unavailable" = "Omslag inte tillgängligt";
"This track has no artwork available." = "Det här spåret har inget omslag tillgängligt.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/tr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Devam etmek için yeni bir sohbet başlatın.";
"Ask Gemini response ready" = "Gemini'ye Sor yanıtı hazır";
"New Ask Gemini chat ready" = "Yeni Gemini sohbeti hazır";
"View Artwork" = "Kapak Görselini Görüntüle";
"Album Artwork" = "Albüm Kapak Görseli";
"Artwork Unavailable" = "Kapak Görseli Kullanılamıyor";
"This track has no artwork available." = "Bu parça için kapak görseli yok.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/uk.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,7 @@
"Start a new chat to continue." = "Почніть новий чат, щоб продовжити.";
"Ask Gemini response ready" = "Відповідь «Запитати Gemini» готова";
"New Ask Gemini chat ready" = "Новий чат «Запитати Gemini» готовий";
"View Artwork" = "Переглянути обкладинку";
"Album Artwork" = "Обкладинка альбому";
"Artwork Unavailable" = "Обкладинка недоступна";
"This track has no artwork available." = "Для цього треку немає обкладинки.";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,7 @@
"e.g., Remove slow songs, reorder by energy..." = "例如:移除慢歌、按能量重新排序…";
"songs" = "首歌曲";
"tracks" = "首曲目";
"View Artwork" = "查看封面";
"Album Artwork" = "专辑封面";
"Artwork Unavailable" = "无可用封面";
"This track has no artwork available." = "此曲目没有可用封面。";
4 changes: 4 additions & 0 deletions Sources/Kaset/Resources/zh-Hant.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,7 @@
"e.g., Remove slow songs, reorder by energy..." = "例如:移除慢歌、依能量重新排序…";
"songs" = "首歌曲";
"tracks" = "首曲目";
"View Artwork" = "查看封面";
"Album Artwork" = "專輯封面";
"Artwork Unavailable" = "無可用封面";
"This track has no artwork available." = "此曲目沒有可用封面。";
33 changes: 33 additions & 0 deletions Sources/Kaset/Utilities/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,39 @@ extension URL {

return URL(string: urlString)
}

/// Returns a square variant of a YouTube artwork URL at the requested pixel size.
///
/// YouTube's image hosts encode the served dimensions in a `w<N>-h<N>` option suffix
/// (`…=w544-h544-l90-rj`). `highQualityThumbnailURL` only lifts list-sized thumbnails to
/// 226px, so full-size surfaces must request their own dimensions. Hosts serve at most
/// the source resolution, so an oversized request degrades to the original artwork.
func artworkURL(side: Int) -> URL? {
guard side > 0,
host?.contains("ytimg.com") == true || host?.contains("googleusercontent.com") == true
else { return self }

let absolute = absoluteString
guard let separatorIndex = absolute.lastIndex(of: "=") else { return self }

let base = absolute[absolute.startIndex ..< separatorIndex]
let options = absolute[absolute.index(after: separatorIndex)...]
guard !options.isEmpty else { return self }

var didResize = false
let rescaled = options.split(separator: "-").map { token -> String in
guard let prefix = token.first,
prefix == "w" || prefix == "h",
token.count > 1,
token.dropFirst().allSatisfy(\.isNumber)
else { return String(token) }
didResize = true
return "\(prefix)\(side)"
}

guard didResize else { return self }
return URL(string: base + "=" + rescaled.joined(separator: "-")) ?? self
}
}

// MARK: - String Extensions
Expand Down
147 changes: 147 additions & 0 deletions Sources/Kaset/Views/ArtworkViewer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import SwiftUI

// MARK: - ArtworkViewerSource

/// Resolves which artwork URL the viewer should display.
///
/// The API-provided artwork is preferred, falling back to YouTube's public video
/// thumbnail when that source fails, mirroring `SongThumbnailView`.
struct ArtworkViewerSource: Equatable {
let primaryURL: URL?
let fallbackURL: URL?

var hasArtwork: Bool {
self.primaryURL != nil || self.fallbackURL != nil
}

/// The distinct fallback, if the primary is worth retrying past.
var retryableFallbackURL: URL? {
guard let fallbackURL, fallbackURL != self.primaryURL else { return nil }
return fallbackURL
}

func activeURL(didFailPrimary: Bool) -> URL? {
if didFailPrimary, let retryableFallbackURL {
return retryableFallbackURL
}
return self.primaryURL ?? self.fallbackURL
}
}

// MARK: - ArtworkViewer

/// A sheet that displays album artwork at high resolution.
///
/// Shows the image centered with a dark background. The viewer can be dismissed
/// by tapping anywhere, pressing Escape, or clicking the close button in the top-right corner.
struct ArtworkViewer: View {
/// Album artwork is square, so the viewer is square too. Scaled against the active
/// screen so it stays generous on large displays without overflowing small ones.
private static var viewerSide: CGFloat {
guard let visibleFrame = NSScreen.main?.visibleFrame else { return 560 }
let side = min(visibleFrame.width, visibleFrame.height) * 0.62
return min(700, max(380, side.rounded()))
}

/// Source artwork is requested well above the display size so the image stays crisp
/// on Retina displays; hosts cap the response at the original resolution.
private static let requestedArtworkPixels = 1200

/// The high-quality thumbnail URL to display.
let artworkURL: URL?

/// Public video thumbnail used when `artworkURL` fails to load.
var fallbackArtworkURL: URL?

/// Called when the viewer should be dismissed.
let onDismiss: () -> Void

@State private var didFailPrimary = false
@State private var didFailArtwork = false

private var source: ArtworkViewerSource {
ArtworkViewerSource(primaryURL: self.artworkURL, fallbackURL: self.fallbackArtworkURL)
}

/// The chosen source before any size upgrade; failure handling is keyed on this.
private var activeSourceURL: URL? {
self.source.activeURL(didFailPrimary: self.didFailPrimary)
}

private func displayURL(for sourceURL: URL) -> URL {
sourceURL.artworkURL(side: Self.requestedArtworkPixels) ?? sourceURL
}

var body: some View {
ZStack {
self.backgroundLayer

if let sourceURL = self.activeSourceURL, !self.didFailArtwork {
CachedAsyncImage(
url: self.displayURL(for: sourceURL),
targetSize: CGSize(width: Self.viewerSide, height: Self.viewerSide),
onFailure: { self.handleFailure(of: sourceURL) },
content: { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
}
)
.id(sourceURL)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
.accessibilityLabel(Text(String(localized: "Album Artwork")))
} else {
self.unavailableArtwork
}
}
.contentShape(Rectangle())
.onTapGesture {
self.onDismiss()
}
.overlay(alignment: .topTrailing) {
self.closeButton
.padding(20)
}
.frame(width: Self.viewerSide, height: Self.viewerSide)
.background(Color.black)
}

// MARK: - Actions

private func handleFailure(of url: URL) {
if url == self.source.primaryURL, self.source.retryableFallbackURL != nil {
self.didFailPrimary = true
return
}
self.didFailArtwork = true
}

// MARK: - Subviews

private var backgroundLayer: some View {
Color.black.opacity(0.85)
}

private var unavailableArtwork: some View {
ContentUnavailableView {
Label(String(localized: "Artwork Unavailable"), systemImage: "photo")
} description: {
Text(String(localized: "This track has no artwork available."))
}
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

private var closeButton: some View {
Button {
self.onDismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 28))
.foregroundStyle(.white.opacity(0.8))
}
.buttonStyle(.plain)
.accessibilityLabel(Text(String(localized: "Close")))
}
}
Loading
Loading