Compare commits

...

8 Commits

Author SHA1 Message Date
plum7x
a3166516ae
Merge f8892c7267 into ba27dc70e4 2025-03-17 15:30:14 +01:00
Kingkor Roy Tirtho
ba27dc70e4
Merge pull request #2550 from KRTirtho/dev
Release 4.0.2
2025-03-16 23:57:54 +06:00
Kingkor Roy Tirtho
0ec9f3535b chore: bump to 4.0.2 and generate changelog 2025-03-16 23:52:08 +06:00
Kingkor Roy Tirtho
df72ba6960 chore: convert all spotify calls to invoke signature to capture invalid access token exception 2025-03-16 21:22:29 +06:00
Kingkor Roy Tirtho
d9057dae57 fix: invalid access token exception #2525 2025-03-16 10:32:41 +06:00
Kingkor Roy Tirtho
e61b79585e chore: remove print statement 2025-03-15 21:09:51 +06:00
Kingkor Roy Tirtho
a9e5636e96 chore: add a fallback init token retrieval method 2025-03-15 21:07:59 +06:00
plum7x
f8892c7267
Create app_zh_TW.arb 2025-02-07 15:59:08 +08:00
39 changed files with 766 additions and 214 deletions

View File

@ -1,6 +1,10 @@
# Changelog
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [4.0.2](https://github.com/krtirtho/spotube/compare/v4.0.1...v4.0.2) (2025-03-16)
### Bug Fixes
- invalid access token exception #2525
## [4.0.1](https://github.com/krtirtho/spotube/compare/v4.0.0...v4.0.1) (2025-03-15)

View File

@ -30,7 +30,6 @@ import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -122,8 +121,9 @@ class TrackOptions extends HookConsumerWidget {
final playlist = ref.read(audioPlayerProvider);
final spotify = ref.read(spotifyProvider);
final query = "${track.name} Radio";
final pages =
await spotify.search.get(query, types: [SearchType.playlist]).first();
final pages = await spotify.invoke(
(api) => api.search.get(query, types: [SearchType.playlist]).first(),
);
final radios = pages
.expand((e) => e.items?.cast<PlaylistSimple>().toList() ?? [])
@ -165,8 +165,9 @@ class TrackOptions extends HookConsumerWidget {
await playback.addTrack(track);
}
final tracks =
await spotify.playlists.getTracksByPlaylistId(radio.id!).all();
final tracks = await spotify.invoke(
(api) => api.playlists.getTracksByPlaylistId(radio.id!).all(),
);
await playback.addTracks(
tracks.toList()

View File

@ -191,8 +191,7 @@ class TrackTile extends HookConsumerWidget {
const SizedBox(
width: 26,
height: 26,
child:
CircularProgressIndicator(size: 1.5),
child: CircularProgressIndicator(),
),
(_, _, true, _, _) => Icon(
SpotubeIcons.pause,

View File

@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:flutter_sharing_intent/flutter_sharing_intent.dart';
import 'package:flutter_sharing_intent/model/sharing_file.dart';
import 'package:spotube/services/logger/logger.dart';
@ -27,7 +27,9 @@ void useDeepLinking(WidgetRef ref, AppRouter router) {
switch (url.pathSegments.first) {
case "album":
final album = await spotify.albums.get(url.pathSegments.last);
final album = await spotify.invoke((api) {
return api.albums.get(url.pathSegments.last);
});
router.navigate(
AlbumRoute(id: album.id!, album: album),
);
@ -36,7 +38,9 @@ void useDeepLinking(WidgetRef ref, AppRouter router) {
router.navigate(ArtistRoute(artistId: url.pathSegments.last));
break;
case "playlist":
final playlist = await spotify.playlists.get(url.pathSegments.last);
final playlist = await spotify.invoke((api) {
return api.playlists.get(url.pathSegments.last);
});
router
.navigate(PlaylistRoute(id: playlist.id!, playlist: playlist));
break;
@ -65,7 +69,9 @@ void useDeepLinking(WidgetRef ref, AppRouter router) {
switch (startSegment) {
case "spotify:album":
final album = await spotify.albums.get(endSegment);
final album = await spotify.invoke((api) {
return api.albums.get(endSegment);
});
await router.navigate(
AlbumRoute(id: album.id!, album: album),
);
@ -77,7 +83,9 @@ void useDeepLinking(WidgetRef ref, AppRouter router) {
await router.navigate(TrackRoute(trackId: endSegment));
break;
case "spotify:playlist":
final playlist = await spotify.playlists.get(endSegment);
final playlist = await spotify.invoke((api) {
return api.playlists.get(endSegment);
});
await router.navigate(
PlaylistRoute(id: playlist.id!, playlist: playlist),
);

View File

@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
@ -28,8 +28,8 @@ void useEndlessPlayback(WidgetRef ref) {
final track = playlist.tracks.last;
final query = "${track.name} Radio";
final pages = await spotify.search
.get(query, types: [SearchType.playlist]).first();
final pages = await spotify.invoke((api) =>
api.search.get(query, types: [SearchType.playlist]).first());
final radios = pages
.expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
@ -50,8 +50,8 @@ void useEndlessPlayback(WidgetRef ref) {
orElse: () => radios.first,
);
final tracks =
await spotify.playlists.getTracksByPlaylistId(radio.id!).all();
final tracks = await spotify.invoke(
(api) => api.playlists.getTracksByPlaylistId(radio.id!).all());
await playback.addTracks(
tracks.toList()

405
lib/l10n/app_zh_TW.arb Normal file
View File

@ -0,0 +1,405 @@
{
"guest": "訪客",
"browse": "瀏覽",
"search": "搜尋",
"library": "音樂庫",
"lyrics": "歌詞",
"settings": "設定",
"genre_categories_filter": "篩選類別或曲風...",
"genre": "曲風",
"personalized": "為您打造",
"featured": "推薦",
"new_releases": "新歌熱播",
"songs": "歌曲",
"playing_track": "播放 {track}",
"queue_clear_alert": "這將清空目前的播放隊列。{track_length} 首歌曲將被移除\n您確定要繼續嗎?",
"load_more": "載入更多",
"playlists": "播放清單",
"artists": "藝人",
"albums": "專輯",
"tracks": "歌曲",
"downloads": "下載",
"filter_playlists": "篩選播放清單...",
"liked_tracks": "已點讚的歌曲",
"liked_tracks_description": "您點贊過的所有歌曲",
"create_playlist": "創建播放清單",
"create_a_playlist": "創建一個播放清單",
"update_playlist": "更新播放清單",
"create": "創建",
"cancel": "取消",
"update": "更新",
"playlist_name": "播放清單名稱",
"name_of_playlist": "播放清單的名稱",
"description": "描述",
"public": "公開",
"collaborative": "共享協作",
"search_local_tracks": "搜尋本機歌曲...",
"play": "播放",
"delete": "刪除",
"none": "無",
"sort_a_z": "依 A-Z 排序",
"sort_z_a": "依 Z-A 排序",
"sort_artist": "依藝人排序",
"sort_album": "依專輯排序",
"sort_duration": "依時長排序",
"sort_tracks": "排序方式",
"currently_downloading": "正在下載 ({tracks_length})",
"cancel_all": "取消全部",
"filter_artist": "篩選藝人...",
"followers": "{followers} 名關注者",
"add_artist_to_blacklist": "將此藝人加入黑名單",
"top_tracks": "熱門歌曲",
"fans_also_like": "粉絲也喜歡",
"loading": "載入中...",
"artist": "藝人",
"blacklisted": "已黑名單",
"following": "關注中",
"follow": "關注",
"artist_url_copied": "藝人的分享連結已複製至剪貼簿",
"added_to_queue": "已新增 {tracks} 首歌曲到播放隊列",
"filter_albums": "篩選專輯...",
"synced": "同步",
"plain": "無同步",
"shuffle": "隨機播放",
"search_tracks": "搜尋歌曲...",
"released": "發行時間",
"error": "錯誤 {error}",
"title": "標題",
"time": "時長",
"more_actions": "更多操作",
"download_count": "下載 ({count}) 首歌曲",
"add_count_to_playlist": "新增 ({count}) 首歌曲到播放清單中",
"add_count_to_queue": "新增 ({count}) 首歌曲到播放隊列中",
"play_count_next": "接下來播放 ({count}) 首歌曲",
"album": "專輯",
"copied_to_clipboard": "已將 {data} 複製至剪貼簿",
"add_to_following_playlists": "新增 {track} 到以下播放清單",
"add": "新增",
"added_track_to_queue": "新增 {track} 到播放隊列",
"add_to_queue": "新增到播放隊列",
"track_will_play_next": "{track} 將在下一首播放",
"play_next": "下一首播放",
"removed_track_from_queue": "將 {track} 從播放隊列中移除",
"remove_from_queue": "從播放隊列移除",
"remove_from_favorites": "取消點贊",
"save_as_favorite": "點贊",
"add_to_playlist": "新增到播放清單",
"remove_from_playlist": "從播放清單中移除",
"add_to_blacklist": "新增到黑名單",
"remove_from_blacklist": "從黑名單中移除",
"share": "分享",
"mini_player": "小窗模式",
"slide_to_seek": "滑動以前進或後退",
"shuffle_playlist": "隨機播放播放清單",
"unshuffle_playlist": "取消隨機播放播放清單",
"previous_track": "上一首歌曲",
"next_track": "下一首歌曲",
"pause_playback": "暫停播放",
"resume_playback": "恢復播放",
"loop_track": "單曲循環",
"repeat_playlist": "播放清單循環",
"queue": "播放隊列",
"alternative_track_sources": "其它音訊來源",
"download_track": "下載歌曲",
"tracks_in_queue": "{tracks} 首歌曲在播放隊列中",
"clear_all": "清除全部",
"show_hide_ui_on_hover": "懸停時顯示/隱藏控制列",
"always_on_top": "置頂",
"exit_mini_player": "退出小窗模式",
"download_location": "下載路徑",
"local_library": "本機音樂庫",
"add_library_location": "新增到音樂庫",
"remove_library_location": "從音樂庫中刪除",
"account": "帳戶",
"login_with_spotify": "使用 Spotify 登入",
"connect_with_spotify": "與 Spotify 帳戶連接",
"logout": "登出",
"logout_of_this_account": "登出該帳戶",
"language_region": "語言和地區",
"language": "語言",
"system_default": "系統預設",
"market_place_region": "市場地區",
"recommendation_country": "選擇國家與地區以獲取對應推薦",
"appearance": "外觀",
"layout_mode": "佈局模式",
"override_layout_settings": "將覆蓋響應式佈局設定",
"adaptive": "自適應",
"compact": "緊湊",
"extended": "寬廣",
"theme": "主題",
"dark": "深色",
"light": "淺色",
"system": "系統",
"accent_color": "主色調",
"sync_album_color": "匹配封面顏色",
"sync_album_color_description": "選取專輯封面主題色作為主色調",
"playback": "播放",
"audio_quality": "音質",
"high": "高",
"low": "低",
"pre_download_play": "先下後播",
"pre_download_play_description": "先下載歌曲後再播放而非流式播放(推薦頻寬較高使用者使用)",
"skip_non_music": "略過非音樂片段(贊助商阻擋)",
"blacklist_description": "已黑名單的歌曲與藝人",
"wait_for_download_to_finish": "請等待目前下載任務完成",
"desktop": "桌面端設定",
"close_behavior": "點擊關閉按鈕行為",
"close": "關閉",
"minimize_to_tray": "最小化到系統匣",
"show_tray_icon": "顯示系統匣圖示",
"about": "關於",
"u_love_spotube": "我們明白您喜歡 Spotube",
"check_for_updates": "檢查更新",
"about_spotube": "關於 Spotube",
"blacklist": "黑名單",
"please_sponsor": "請贊助/捐贈",
"spotube_description": "Spotube一個輕量、跨平台且完全免費的 Spotify 客戶端。",
"version": "版本",
"build_number": "建置代碼",
"founder": "發起人",
"repository": "儲存庫",
"bug_issues": "錯誤與問題回報",
"made_with": "於孟加拉🇧🇩用 ❤️ 發電",
"kingkor_roy_tirtho": "Kingkor Roy Tirtho",
"copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
"license": "許可證",
"add_spotify_credentials": "新增您的 Spotify 登入資訊以開始使用",
"credentials_will_not_be_shared_disclaimer": "不用擔心,軟體不會收集或分享任何個人資料給第三方",
"know_how_to_login": "不知道該怎麼做?",
"follow_step_by_step_guide": "請依照以下指南進行",
"spotify_cookie": "Spotify {name} Cookie",
"cookie_name_cookie": "{name} Cookie",
"fill_in_all_fields": "請填寫所有欄位",
"submit": "提交",
"exit": "退出",
"previous": "上一步",
"next": "下一步",
"done": "完成",
"step_1": "步驟 1",
"first_go_to": "首先,前往",
"login_if_not_logged_in": "如果尚未登入,請登入或者註冊一個帳戶",
"step_2": "步驟 2",
"step_2_steps": "1. 一旦您已經完成登入, 按 F12 鍵或者滑鼠右擊網頁空白區域 > 選擇「檢查」以打開瀏覽器開發者工具DevTools\n2. 然後選擇 \"應用Application\" 標籤頁Chrome、Edge、Brave 等基於 Chromium 的瀏覽器) 或 \"存儲Storage\" 標籤頁 Firefox、Palemoon 等基於 Firefox 的瀏覽器))\n3. 選擇 \"Cookies\" 欄位然後選擇 \"https://accounts.spotify.com\" 子欄位",
"step_3": "步驟 3",
"step_3_steps": "複製 \"sp_dc\" Cookie 的值",
"success_emoji": "成功🥳",
"success_message": "您已經成功使用 Spotify 登入。幹得漂亮!",
"step_4": "步驟 4",
"step_4_steps": "貼上複製的 \"sp_dc\" 值",
"something_went_wrong": "某些地方出現了問題",
"piped_instance": "管線伺服器實例",
"piped_description": "管線伺服器實例用於匹配歌曲",
"piped_warning": "它們中的一部分可能並不能正常工作。使用時請自行承擔風險",
"invidious_instance": "Invidious 伺服器實例",
"invidious_description": "用於音軌匹配的 Invidious 伺服器實例",
"invidious_warning": "有些可能無法正常工作。請自行承擔風險",
"generate_playlist": "產生播放清單",
"track_exists": "歌曲 {track} 已存在",
"replace_downloaded_tracks": "取代已下載的歌曲",
"skip_download_tracks": "下載時略過已下載的歌曲",
"do_you_want_to_replace": "您確定要取代已下載的歌曲嗎??",
"replace": "取代",
"skip": "略過",
"select_up_to_count_type": "選擇多達 {count} 種的類型 {type}",
"select_genres": "選擇曲風",
"add_genres": "新增曲風",
"country": "國家與地區",
"number_of_tracks_generate": "產生歌曲的數目",
"acousticness": "原聲程度",
"danceability": "律動感",
"energy": "衝擊感",
"instrumentalness": "歌唱部分佔比",
"liveness": "現場感",
"loudness": "響度",
"speechiness": "朗誦比例",
"valence": "心理感受",
"popularity": "流行度",
"key": "曲調",
"duration": "歌曲時長 (s)",
"tempo": "分鐘節拍數 (BPM)",
"mode": "旋律重複度",
"time_signature": "音符時值",
"short": "短",
"medium": "中",
"long": "長",
"min": "最低",
"max": "最高",
"target": "目標",
"moderate": "中",
"deselect_all": "取消全選",
"select_all": "全選",
"are_you_sure": "您確定嗎?",
"generating_playlist": "正在產生您的自訂播放清單...",
"selected_count_tracks": "已選擇 {count} 首歌曲",
"download_warning": "如果您大量下載這些歌曲,您顯然在侵犯音樂的版權並對音樂創作社區造成了傷害。我希望您能意識到這一點。永遠要尊重並支持藝人們的辛勤工作",
"download_ip_ban_warning": "小心,如果出現超出正常的下載請求那您的 IP 可能會被 YouTube 封鎖,這意味著您的裝置將在長達 2-3 個月的時間內無法使用該 IP 存取 YouTube即使您沒登入。Spotube 對此不承擔任何責任",
"by_clicking_accept_terms": "點擊『同意』代表著您同意以下的條款",
"download_agreement_1": "我明白侵犯音樂版權是一件不好的事情",
"download_agreement_2": "我將盡可能支持藝人的工作。我現在之所以做不到是因為缺乏資金來購買正版",
"download_agreement_3": "我完全瞭解我的 IP 存在被 YouTube 的風險。我同意 Spotube 的所有者與貢獻者們無須對我目前的行為所導致的任何後果負責",
"decline": "拒絕",
"accept": "同意",
"details": "詳細資料",
"youtube": "YouTube",
"channel": "頻道",
"likes": "贊",
"dislikes": "踩",
"views": "瀏覽次數",
"streamUrl": "播放串流 URL",
"stop": "停止",
"sort_newest": "依最新新增排序",
"sort_oldest": "依最舊新增排序",
"sleep_timer": "睡眠定時器",
"mins": "{minutes} 分",
"hours": "{hours} 時",
"hour": "{hours} 時",
"custom_hours": "自訂時間",
"logs": "日誌",
"developers": "開發者",
"not_logged_in": "您尚未登入",
"search_mode": "搜尋模式",
"audio_source": "音訊來源",
"ok": "確定",
"failed_to_encrypt": "加密失敗",
"encryption_failed_warning": "Spotube 使用加密來安全地存儲您的資料。但是失敗了。因此,它將回退到不安全的存儲\n如果您使用 Linux請確保已安裝 gnome-keyring、kde-wallet 與 keepassxc 等秘密服務",
"querying_info": "正在查詢資訊...",
"piped_api_down": "Piped API不可用",
"piped_down_error_instructions": "目前 Piped 實例 {pipedInstance} 不可用\n\n請更改實例或將『API 類型』更改為官方 YouTube API\n\n更改後請確保重新啟動應用程式",
"you_are_offline": "您目前處於離線狀態",
"connection_restored": "您的網際網路連接已恢復",
"use_system_title_bar": "使用系統標題列",
"crunching_results": "處理結果中...",
"search_to_get_results": "搜尋以獲取結果",
"use_amoled_mode": "使用 AMOLED 模式",
"pitch_dark_theme": "深色主題",
"normalize_audio": "標準化音訊",
"change_cover": "更改封面",
"add_cover": "新增封面",
"restore_defaults": "恢復預設值",
"download_music_codec": "下載音樂編解碼器",
"streaming_music_codec": "串流媒體音樂編解碼器",
"login_with_lastfm": "使用 Last.fm 登入",
"connect": "連接",
"disconnect_lastfm": "斷開 Last.fm 連接",
"disconnect": "斷開連接",
"username": "使用者名稱",
"password": "密碼",
"login": "登入",
"login_with_your_lastfm": "使用您的 Last.fm 帳戶登入",
"scrobble_to_lastfm": "在 Last.fm 上記錄播放",
"go_to_album": "前往專輯",
"discord_rich_presence": "Discord 豐富展現",
"browse_all": "瀏覽全部",
"genres": "曲風",
"explore_genres": "探索曲風",
"friends": "朋友",
"no_lyrics_available": "抱歉,無法找到此曲的歌詞",
"start_a_radio": "開始收聽電台",
"how_to_start_radio": "您想如何開始收聽電台?",
"replace_queue_question": "您想要取代目前隊列還是追加到隊列?",
"endless_playback": "無盡播放",
"delete_playlist": "刪除播放清單",
"delete_playlist_confirmation": "您確定要刪除此播放清單嗎?",
"local_tracks": "本機音軌",
"local_tab": "本機",
"song_link": "歌曲連結",
"skip_this_nonsense": "略過此無聊內容",
"freedom_of_music": "「音樂的自由」",
"freedom_of_music_palm": "「音樂的自由掌握在您手中」",
"get_started": "讓我們開始吧",
"youtube_source_description": "推薦並且效果最佳。",
"piped_source_description": "感覺自由?與 YouTube 一樣但更自由。",
"jiosaavn_source_description": "最適合南亞地區。",
"invidious_source_description": "類似於 Piped但可用性更高。",
"highest_quality": "最高音質:{quality}",
"select_audio_source": "選擇音訊來源",
"endless_playback_description": "自動將新歌曲新增到隊列的末尾",
"choose_your_region": "選擇您的地區",
"choose_your_region_description": "這將幫助 Spotube 為您的位置顯示正確的內容。",
"choose_your_language": "選擇您的語言",
"help_project_grow": "幫助這個專案成長",
"help_project_grow_description": "Spotube 是一個開源專案。您可以通過為專案做出貢獻、回報錯誤或建議新功能來幫助該專案成長。",
"contribute_on_github": "在 GitHub 上做出貢獻",
"donate_on_open_collective": "在 Open Collective 上捐款",
"browse_anonymously": "匿名瀏覽",
"enable_connect": "啟用連接",
"enable_connect_description": "從其他裝置控制Spotube",
"devices": "裝置",
"select": "選擇",
"connect_client_alert": "您正在被 {client} 控制",
"this_device": "此裝置",
"remote": "遠端",
"stats": "統計",
"and_n_more": "和 {count} 更多",
"recently_played": "最近播放",
"browse_more": "瀏覽更多",
"no_title": "沒有標題",
"not_playing": "未播放",
"epic_failure": "史詩級失敗!",
"added_num_tracks_to_queue": "已將 {tracks_length} 首曲目新增到隊列",
"spotube_has_an_update": "Spotube 有更新",
"download_now": "立即下載",
"nightly_version": "Spotube Nightly {nightlyBuildNum} 已發佈",
"release_version": "Spotube v{version} 已發佈",
"read_the_latest": "閱讀最新",
"release_notes": "版本說明",
"pick_color_scheme": "選擇配色方案",
"save": "儲存",
"choose_the_device": "選擇裝置:",
"multiple_device_connected": "已連接多個裝置。\n選擇您希望執行此操作的裝置",
"nothing_found": "沒有找到任何內容",
"the_box_is_empty": "箱子為空",
"top_artists": "熱門藝人",
"top_albums": "熱門專輯",
"this_week": "本週",
"this_month": "本月",
"last_6_months": "過去6個月",
"this_year": "今年",
"last_2_years": "過去2年",
"all_time": "所有時間",
"powered_by_provider": "由 {providerName} 提供支援",
"email": "電子郵件",
"profile_followers": "關注者",
"birthday": "生日",
"subscription": "訂閱",
"not_born": "尚未出生",
"hacker": "黑客",
"profile": "個人資料",
"no_name": "無名",
"edit": "編輯",
"user_profile": "使用者資料",
"count_plays": "{count} 次播放",
"streaming_fees_hypothetical": "*基於 Spotify 每次播放的支付金額\n從 $0.003 到 $0.005 計算。這是一個假設性的\n計算旨在讓使用者瞭解如果他們在 Spotify 上收聽\n這些歌曲可能會付給藝人的金額。",
"minutes_listened": "聽的分鐘數",
"streamed_songs": "已串流媒體歌曲",
"count_streams": "{count} 次串流媒體",
"owned_by_you": "由您擁有",
"copied_shareurl_to_clipboard": "{shareUrl} 已複製到剪貼簿",
"spotify_hipotetical_calculation": "*根據 Spotify 每次串流媒體的支付金額\n$0.003 到 $0.005 進行計算。這是一個假設性的\n計算用於給使用者瞭解他們如果在 Spotify 上\n收聽歌曲會支付給藝人的金額。",
"count_mins": "{minutes} 分鐘",
"summary_minutes": "分鐘",
"summary_listened_to_music": "聽音樂",
"summary_songs": "歌曲",
"summary_streamed_overall": "總體串流媒體",
"summary_owed_to_artists": "本月欠藝人的",
"summary_artists": "藝人的",
"summary_music_reached_you": "音樂觸及了您",
"summary_full_albums": "完整專輯",
"summary_got_your_love": "獲得了您的愛",
"summary_playlists": "播放清單",
"summary_were_on_repeat": "已重複播放",
"total_money": "總計 {money}",
"webview_not_found": "沒有發現 Webview",
"webview_not_found_description": "您的裝置中未安裝 Webview 運行時。\n如果已安裝請確保它在環境變數 PATH 中\n\n安裝後重新啟動應用程式",
"unsupported_platform": "不支援的平台",
"cache_music": "快取音樂",
"open": "打開",
"cache_folder": "快取資料夾",
"export": "匯出",
"clear_cache": "清除快取",
"clear_cache_confirmation": "您要清除快取嗎?",
"export_cache_files": "匯出快取檔案",
"found_n_files": "找到 {count} 個檔案",
"export_cache_confirmation": "您要匯出這些檔案到",
"exported_n_out_of_m_files": "匯出了 {filesExported} / {files} 個檔案"
}

View File

@ -8,7 +8,7 @@ import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/models/spotify_friends.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart';
class FriendItem extends HookConsumerWidget {
final SpotifyFriendActivity friend;
@ -95,8 +95,9 @@ class FriendItem extends HookConsumerWidget {
text: " ${friend.track.album.name}",
recognizer: TapGestureRecognizer()
..onTap = () async {
final album =
await spotify.albums.get(friend.track.album.id);
final album = await spotify.invoke(
(api) => api.albums.get(friend.track.album.id),
);
if (context.mounted) {
context.navigateTo(
AlbumRoute(id: album.id!, album: album),

View File

@ -19,7 +19,6 @@ import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart';
class PlaylistCreateDialog extends HookConsumerWidget {
/// Track ids to add to the playlist
@ -260,7 +259,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
class PlaylistCreateDialogButton extends HookConsumerWidget {
const PlaylistCreateDialogButton({super.key});
showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
showPlaylistDialog(BuildContext context, SpotifyApiWrapper spotify) {
showDialog(
context: context,
alignment: Alignment.center,

View File

@ -22,7 +22,6 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:auto_route/auto_route.dart';
@ -70,22 +69,24 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
leftSeedCount,
context.l10n.artists,
)),
fetchSeeds: (textEditingValue) => spotify.search
.get(
textEditingValue.text,
types: [SearchType.artist],
)
.first(6)
.then(
(v) => List.castFrom<dynamic, Artist>(
v.expand((e) => e.items ?? []).toList(),
fetchSeeds: (textEditingValue) => spotify.invoke(
(api) => api.search
.get(
textEditingValue.text,
types: [SearchType.artist],
)
.where(
(element) =>
artists.value.none((artist) => element.id == artist.id),
)
.toList(),
),
.first(6)
.then(
(v) => List.castFrom<dynamic, Artist>(
v.expand((e) => e.items ?? []).toList(),
)
.where(
(element) =>
artists.value.none((artist) => element.id == artist.id),
)
.toList(),
),
),
autocompleteOptionBuilder: (option, onSelected) => ButtonTile(
leading: Avatar(
initials: "O",
@ -146,22 +147,24 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
leftSeedCount,
context.l10n.tracks,
)),
fetchSeeds: (textEditingValue) => spotify.search
.get(
textEditingValue.text,
types: [SearchType.track],
)
.first(6)
.then(
(v) => List.castFrom<dynamic, Track>(
v.expand((e) => e.items ?? []).toList(),
fetchSeeds: (textEditingValue) => spotify.invoke(
(api) => api.search
.get(
textEditingValue.text,
types: [SearchType.track],
)
.where(
(element) =>
tracks.value.none((track) => element.id == track.id),
)
.toList(),
),
.first(6)
.then(
(v) => List.castFrom<dynamic, Track>(
v.expand((e) => e.items ?? []).toList(),
)
.where(
(element) =>
tracks.value.none((track) => element.id == track.id),
)
.toList(),
),
),
autocompleteOptionBuilder: (option, onSelected) => ButtonTile(
leading: Avatar(
initials: option.name!.substring(0, 1),

View File

@ -15,6 +15,7 @@ import 'package:spotube/components/dialogs/prompt_dialog.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/utils/platform.dart';
import 'package:otp_util/otp_util.dart';
// ignore: implementation_imports
@ -197,6 +198,34 @@ class AuthenticationNotifier extends AsyncNotifier<AuthenticationTableData?> {
);
}
Future<Response> getToken({
required String totp,
required int timestamp,
String mode = "transport",
String? spDc,
}) async {
assert(mode == "transport" || mode == "init");
final accessTokenUrl = Uri.parse(
"https://open.spotify.com/get_access_token?reason=$mode&productType=web-player"
"&totp=$totp&totpVer=5&ts=$timestamp",
);
final res = await dio.getUri(
accessTokenUrl,
options: Options(
headers: {
"Cookie": spDc ?? "",
"User-Agent": ServiceUtils.randomUserAgent(
kIsDesktop ? UserAgentDevice.desktop : UserAgentDevice.mobile,
),
},
),
);
return res;
}
Future<AuthenticationTableCompanion> credentialsFromCookie(
String cookie,
) async {
@ -207,24 +236,34 @@ class AuthenticationNotifier extends AsyncNotifier<AuthenticationTableData?> {
?.trim();
final totp = await generateTotp();
final timestamp = (DateTime.now().millisecondsSinceEpoch / 1000).floor();
final accessTokenUrl = Uri.parse(
"https://open.spotify.com/get_access_token?reason=transport&productType=web_player"
"&totp=$totp&totpVer=5&ts=$timestamp",
var res = await getToken(
totp: totp,
timestamp: timestamp,
spDc: spDc,
mode: "transport",
);
final res = await dio.getUri(
accessTokenUrl,
options: Options(
headers: {
"Cookie": spDc ?? "",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
},
),
);
final body = res.data;
if ((res.data["accessToken"]?.length ?? 0) != 374) {
res = await getToken(
totp: totp,
timestamp: timestamp,
spDc: spDc,
mode: "init",
);
}
final body = res.data as Map<String, dynamic>;
if (body["accessToken"] == null) {
AppLogger.reportError(
"The access token is only ${body["accessToken"]?.length} characters long instead of 374\n"
"Your authentication probably doesn't work",
StackTrace.current,
);
}
return AuthenticationTableCompanion.insert(
id: const Value(0),

View File

@ -1,6 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/custom_spotify_endpoints/spotify_endpoints.dart';
final customSpotifyEndpointProvider = Provider<CustomSpotifyEndpoints>((ref) {

View File

@ -76,8 +76,6 @@ final localTracksProvider =
final mime = lookupMimeType(e.path) ??
(extension(e.path) == ".opus" ? "audio/opus" : null);
print("${basename(e.path)}: $mime");
return e is File && supportedAudioTypes.contains(mime);
},
).cast<File>(),

View File

@ -22,11 +22,14 @@ class FavoriteAlbumState extends PaginatedState<AlbumSimple> {
class FavoriteAlbumNotifier
extends PaginatedAsyncNotifier<AlbumSimple, FavoriteAlbumState> {
@override
Future<List<AlbumSimple>> fetch(int offset, int limit) {
return spotify.me
.savedAlbums()
.getPage(limit, offset)
.then((value) => value.items?.toList() ?? []);
Future<List<AlbumSimple>> fetch(int offset, int limit) async {
return await spotify
.invoke(
(api) => api.me.savedAlbums().getPage(limit, offset),
)
.then(
(value) => value.items?.toList() ?? <AlbumSimple>[],
);
}
@override
@ -45,8 +48,10 @@ class FavoriteAlbumNotifier
if (state.value == null) return;
state = await AsyncValue.guard(() async {
await spotify.me.saveAlbums(ids);
final albums = await spotify.albums.list(ids);
await spotify.invoke((api) => api.me.saveAlbums(ids));
final albums = await spotify.invoke(
(api) => api.albums.list(ids),
);
return state.value!.copyWith(
items: [
@ -65,7 +70,7 @@ class FavoriteAlbumNotifier
if (state.value == null) return;
state = await AsyncValue.guard(() async {
await spotify.me.removeAlbums(ids);
await spotify.invoke((api) => api.me.removeAlbums(ids));
return state.value!.copyWith(
items: state.value!.items

View File

@ -3,8 +3,10 @@ part of '../spotify.dart';
final albumsIsSavedProvider = FutureProvider.autoDispose.family<bool, String>(
(ref, albumId) async {
final spotify = ref.watch(spotifyProvider);
return spotify.me.containsSavedAlbums([albumId]).then(
(value) => value[albumId] ?? false,
return spotify.invoke(
(api) => api.me.containsSavedAlbums([albumId]).then(
(value) => value[albumId] ?? false,
),
);
},
);

View File

@ -32,9 +32,9 @@ class AlbumReleasesNotifier
fetch(int offset, int limit) async {
final market = ref.read(userPreferencesProvider).market;
final albums = await spotify.browse
.newReleases(country: market)
.getPage(limit, offset);
final albums = await spotify.invoke(
(api) => api.browse.newReleases(country: market).getPage(limit, offset),
);
return albums.items?.map((album) => album.toAlbum()).toList() ?? [];
}

View File

@ -30,7 +30,9 @@ class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier<Track,
@override
fetch(arg, offset, limit) async {
final tracks = await spotify.albums.tracks(arg.id!).getPage(limit, offset);
final tracks = await spotify.invoke(
(api) => api.albums.tracks(arg.id!).getPage(limit, offset),
);
final items = tracks.items?.map((e) => e.asTrack(arg)).toList() ?? [];
return (

View File

@ -31,9 +31,9 @@ class ArtistAlbumsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier<
@override
fetch(arg, offset, limit) async {
final market = ref.read(userPreferencesProvider).market;
final albums = await spotify.artists
.albums(arg, country: market)
.getPage(limit, offset);
final albums = await spotify.invoke(
(api) => api.artists.albums(arg, country: market).getPage(limit, offset),
);
final items = albums.items?.toList() ?? [];

View File

@ -6,5 +6,5 @@ final artistProvider =
final spotify = ref.watch(spotifyProvider);
return spotify.artists.get(artistId);
return spotify.invoke((api) => api.artists.get(artistId));
});

View File

@ -33,10 +33,12 @@ class FollowedArtistsNotifier
@override
fetch(offset, limit) async {
final artists = await spotify.me.following(FollowingType.artist).getPage(
limit,
offset ?? '',
);
final artists = await spotify.invoke(
(api) => api.me.following(FollowingType.artist).getPage(
limit,
offset ?? '',
),
);
return (artists.items?.toList() ?? [], artists.after);
}
@ -55,7 +57,9 @@ class FollowedArtistsNotifier
Future<void> _followArtists(List<String> artistIds) async {
try {
final creds = await spotify.getCredentials();
final creds = await spotify.invoke(
(api) => api.getCredentials(),
);
await dio.post(
"https://api-partner.spotify.com/pathfinder/v1/query",
@ -93,7 +97,9 @@ class FollowedArtistsNotifier
await _followArtists(artistIds);
state = await AsyncValue.guard(() async {
final artists = await spotify.artists.list(artistIds);
final artists = await spotify.invoke(
(api) => api.artists.list(artistIds),
);
return state.value!.copyWith(
items: [
@ -110,7 +116,9 @@ class FollowedArtistsNotifier
Future<void> removeArtists(List<String> artistIds) async {
if (state.value == null) return;
await spotify.me.unfollow(FollowingType.artist, artistIds);
await spotify.invoke(
(api) => api.me.unfollow(FollowingType.artist, artistIds),
);
state = await AsyncValue.guard(() async {
final artists = state.value!.items.where((artist) {
@ -136,7 +144,9 @@ final followedArtistsProvider =
final allFollowedArtistsProvider = FutureProvider<List<Artist>>(
(ref) async {
final spotify = ref.watch(spotifyProvider);
final artists = await spotify.me.following(FollowingType.artist).all();
final artists = await spotify.invoke(
(api) => api.me.following(FollowingType.artist).all(),
);
return artists.toList();
},
);

View File

@ -3,8 +3,10 @@ part of '../spotify.dart';
final artistIsFollowingProvider = FutureProvider.family(
(ref, String artistId) async {
final spotify = ref.watch(spotifyProvider);
return spotify.me.checkFollowing(FollowingType.artist, [artistId]).then(
(value) => value[artistId] ?? false,
return spotify.invoke(
(api) => api.me.checkFollowing(FollowingType.artist, [artistId]).then(
(value) => value[artistId] ?? false,
),
);
},
);

View File

@ -5,7 +5,9 @@ final relatedArtistsProvider = FutureProvider.autoDispose
ref.cacheFor();
final spotify = ref.watch(spotifyProvider);
final artists = await spotify.artists.relatedArtists(artistId);
final artists = await spotify.invoke(
(api) => api.artists.relatedArtists(artistId),
);
return artists.toList();
});

View File

@ -7,7 +7,9 @@ final artistTopTracksProvider =
final spotify = ref.watch(spotifyProvider);
final market = ref.watch(userPreferencesProvider.select((s) => s.market));
final tracks = await spotify.artists.topTracks(artistId, market);
final tracks = await spotify.invoke(
(api) => api.artists.topTracks(artistId, market),
);
return tracks.toList();
},

View File

@ -5,14 +5,16 @@ final categoriesProvider = FutureProvider(
final spotify = ref.watch(spotifyProvider);
final market = ref.watch(userPreferencesProvider.select((s) => s.market));
final locale = ref.watch(userPreferencesProvider.select((s) => s.locale));
final categories = await spotify.categories
.list(
country: market,
locale: Intl.canonicalizedLocale(
locale.toString(),
),
)
.all();
final categories = await spotify.invoke(
(api) => api.categories
.list(
country: market,
locale: Intl.canonicalizedLocale(
locale.toString(),
),
)
.all(),
);
return categories.toList()..shuffle();
},

View File

@ -32,7 +32,7 @@ class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier<
fetch(arg, offset, limit) async {
final preferences = ref.read(userPreferencesProvider);
final playlists = await Pages<PlaylistSimple?>(
spotify,
spotify.api,
"v1/browse/categories/$arg/playlists?country=${preferences.market.name}&locale=${preferences.locale}",
(json) => json == null ? null : PlaylistSimple.fromJson(json),
'playlists',

View File

@ -138,7 +138,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier<SubtitleSimple, Track?> {
SubtitleSimple? lyrics = cachedLyrics;
final token = await spotify.getCredentials();
final token = await spotify.invoke((api) => api.getCredentials());
if ((lyrics == null || lyrics.lyrics.isEmpty) && auth != null) {
lyrics = await getSpotifyLyrics(token.accessToken);

View File

@ -30,9 +30,11 @@ class FavoritePlaylistsNotifier
@override
fetch(int offset, int limit) async {
final playlists = await spotify.playlists.me.getPage(
limit,
offset,
final playlists = await spotify.invoke(
(api) => api.playlists.me.getPage(
limit,
offset,
),
);
return playlists.items?.toList() ?? [];
@ -67,7 +69,9 @@ class FavoritePlaylistsNotifier
Future<void> addFavorite(PlaylistSimple playlist) async {
await update((state) async {
await spotify.playlists.followPlaylist(playlist.id!);
await spotify.invoke(
(api) => api.playlists.followPlaylist(playlist.id!),
);
return state.copyWith(
items: [...state.items, playlist],
);
@ -78,7 +82,9 @@ class FavoritePlaylistsNotifier
Future<void> removeFavorite(PlaylistSimple playlist) async {
await update((state) async {
await spotify.playlists.unfollowPlaylist(playlist.id!);
await spotify.invoke(
(api) => api.playlists.unfollowPlaylist(playlist.id!),
);
return state.copyWith(
items: state.items.where((e) => e.id != playlist.id).toList(),
);
@ -92,9 +98,11 @@ class FavoritePlaylistsNotifier
final spotify = ref.read(spotifyProvider);
await spotify.playlists.addTracks(
trackIds.map((id) => 'spotify:track:$id').toList(),
playlistId,
await spotify.invoke(
(api) => api.playlists.addTracks(
trackIds.map((id) => 'spotify:track:$id').toList(),
playlistId,
),
);
ref.invalidate(playlistTracksProvider(playlistId));
@ -105,9 +113,11 @@ class FavoritePlaylistsNotifier
final spotify = ref.read(spotifyProvider);
await spotify.playlists.removeTracks(
trackIds.map((id) => 'spotify:track:$id').toList(),
playlistId,
await spotify.invoke(
(api) => api.playlists.removeTracks(
trackIds.map((id) => 'spotify:track:$id').toList(),
playlistId,
),
);
ref.invalidate(playlistTracksProvider(playlistId));
@ -128,8 +138,8 @@ final isFavoritePlaylistProvider = FutureProvider.family<bool, String>(
return false;
}
final follows =
await spotify.playlists.followedByUsers(id, [me.value!.id!]);
final follows = await spotify
.invoke((api) => api.playlists.followedByUsers(id, [me.value!.id!]));
return follows[me.value!.id!] ?? false;
},

View File

@ -30,9 +30,8 @@ class FeaturedPlaylistsNotifier
@override
fetch(int offset, int limit) async {
final playlists = await spotify.playlists.featured.getPage(
limit,
offset,
final playlists = await spotify.invoke(
(api) => api.playlists.featured.getPage(limit, offset),
);
return playlists.items?.toList() ?? [];

View File

@ -8,32 +8,36 @@ final generatePlaylistProvider = FutureProvider.autoDispose
userPreferencesProvider.select((s) => s.market),
);
final recommendation = await spotify.recommendations
.get(
limit: input.limit,
seedArtists: input.seedArtists?.toList(),
seedGenres: input.seedGenres?.toList(),
seedTracks: input.seedTracks?.toList(),
market: market,
max: (input.max?.toJson()?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
min: (input.min?.toJson()?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
target: (input.target?.toJson()
?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
)
.catchError((e, stackTrace) {
AppLogger.reportError(e, stackTrace);
return Recommendations();
});
final recommendation = await spotify.invoke(
(api) => api.recommendations
.get(
limit: input.limit,
seedArtists: input.seedArtists?.toList(),
seedGenres: input.seedGenres?.toList(),
seedTracks: input.seedTracks?.toList(),
market: market,
max: (input.max?.toJson()?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
min: (input.min?.toJson()?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
target: (input.target?.toJson()
?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
)
.catchError((e, stackTrace) {
AppLogger.reportError(e, stackTrace);
return Recommendations();
}),
);
if (recommendation.tracks?.isEmpty ?? true) {
return [];
}
final tracks = await spotify.tracks
.list(recommendation.tracks!.map((e) => e.id!).toList());
final tracks = await spotify.invoke(
(api) =>
api.tracks.list(recommendation.tracks!.map((e) => e.id!).toList()),
);
return tracks.toList();
},

View File

@ -4,7 +4,9 @@ class LikedTracksNotifier extends AsyncNotifier<List<Track>> {
@override
FutureOr<List<Track>> build() async {
final spotify = ref.watch(spotifyProvider);
final savedTracked = await spotify.tracks.me.saved.all();
final savedTracked = await spotify.invoke(
(api) => api.tracks.me.saved.all(),
);
return savedTracked.map((e) => e.track!).toList();
}
@ -17,10 +19,14 @@ class LikedTracksNotifier extends AsyncNotifier<List<Track>> {
final isLiked = tracks.map((e) => e.id).contains(track.id);
if (isLiked) {
await spotify.tracks.me.removeOne(track.id!);
await spotify.invoke(
(api) => api.tracks.me.removeOne(track.id!),
);
return tracks.where((e) => e.id != track.id).toList();
} else {
await spotify.tracks.me.saveOne(track.id!);
await spotify.invoke(
(api) => api.tracks.me.saveOne(track.id!),
);
return [track, ...tracks];
}
});

View File

@ -12,7 +12,9 @@ class PlaylistNotifier extends FamilyAsyncNotifier<Playlist, String> {
@override
FutureOr<Playlist> build(String arg) {
final spotify = ref.watch(spotifyProvider);
return spotify.playlists.get(arg);
return spotify.invoke(
(api) => api.playlists.get(arg),
);
}
Future<void> create(PlaylistInput input, [ValueChanged? onError]) async {
@ -26,18 +28,22 @@ class PlaylistNotifier extends FamilyAsyncNotifier<Playlist, String> {
state = await AsyncValue.guard(() async {
try {
final playlist = await spotify.playlists.createPlaylist(
me.value!.id!,
input.playlistName,
collaborative: input.collaborative,
description: input.description,
public: input.public,
final playlist = await spotify.invoke(
(api) => api.playlists.createPlaylist(
me.value!.id!,
input.playlistName,
collaborative: input.collaborative,
description: input.description,
public: input.public,
),
);
if (input.base64Image != null) {
await spotify.playlists.updatePlaylistImage(
playlist.id!,
input.base64Image!,
await spotify.invoke(
(api) => api.playlists.updatePlaylistImage(
playlist.id!,
input.base64Image!,
),
);
}
@ -58,21 +64,27 @@ class PlaylistNotifier extends FamilyAsyncNotifier<Playlist, String> {
await update((state) async {
try {
await spotify.playlists.updatePlaylist(
state.id!,
input.playlistName,
collaborative: input.collaborative,
description: input.description,
public: input.public,
await spotify.invoke(
(api) => api.playlists.updatePlaylist(
state.id!,
input.playlistName,
collaborative: input.collaborative,
description: input.description,
public: input.public,
),
);
if (input.base64Image != null) {
await spotify.playlists.updatePlaylistImage(
state.id!,
input.base64Image!,
await spotify.invoke(
(api) => api.playlists.updatePlaylistImage(
state.id!,
input.base64Image!,
),
);
final playlist = await spotify.playlists.get(state.id!);
final playlist = await spotify.invoke(
(api) => api.playlists.get(state.id!),
);
ref.read(favoritePlaylistsProvider.notifier).updatePlaylist(playlist);
return playlist;
@ -105,9 +117,11 @@ class PlaylistNotifier extends FamilyAsyncNotifier<Playlist, String> {
final spotify = ref.read(spotifyProvider);
await spotify.playlists.addTracks(
trackIds.map((id) => "spotify:track:$id").toList(),
state.value!.id!,
await spotify.invoke(
(api) => api.playlists.addTracks(
trackIds.map((id) => "spotify:track:$id").toList(),
state.value!.id!,
),
);
} catch (e, stack) {
onError?.call(e);

View File

@ -30,9 +30,9 @@ class PlaylistTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier<
@override
fetch(arg, offset, limit) async {
final tracks = await spotify.playlists
.getTracksByPlaylistId(arg)
.getPage(limit, offset);
final tracks = await spotify.invoke(
(api) => api.playlists.getTracksByPlaylistId(arg).getPage(limit, offset),
);
/// Filter out tracks with null id because some personal playlists
/// may contain local tracks that are not available in the Spotify catalog

View File

@ -44,13 +44,15 @@ class SearchNotifier<Y> extends AutoDisposeFamilyPaginatedAsyncNotifier<Y,
nextOffset: 0,
);
}
final results = await spotify.search
.get(
ref.read(searchTermStateProvider),
types: [arg],
market: ref.read(userPreferencesProvider).market,
)
.getPage(limit, offset);
final results = await spotify.invoke(
(api) => api.search
.get(
ref.read(searchTermStateProvider),
types: [arg],
market: ref.read(userPreferencesProvider).market,
)
.getPage(limit, offset),
);
final items = results.expand((e) => e.items ?? <Y>[]).toList().cast<Y>();

View File

@ -5,6 +5,7 @@ import 'dart:math';
import 'package:drift/drift.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/database/database.dart';
@ -25,10 +26,10 @@ import 'package:spotube/models/lyrics.dart';
import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/models/spotify_friends.dart';
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/dio/dio.dart';
import 'package:spotube/services/wikipedia/wikipedia.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:wikipedia_api/wikipedia_api.dart';
@ -76,3 +77,57 @@ part 'utils/provider/paginated.dart';
part 'utils/provider/cursor.dart';
part 'utils/provider/paginated_family.dart';
part 'utils/provider/cursor_family.dart';
class SpotifyApiWrapper {
final SpotifyApi api;
final Ref ref;
SpotifyApiWrapper(
this.ref,
this.api,
);
bool _isRefreshing = false;
FutureOr<T> invoke<T>(
FutureOr<T> Function(SpotifyApi api) fn,
) async {
try {
return await fn(api);
} catch (e) {
if (((e is AuthorizationException && e.error == 'invalid_token') ||
e is ExpirationException) &&
!_isRefreshing) {
_isRefreshing = true;
await ref.read(authenticationProvider.notifier).refreshCredentials();
_isRefreshing = false;
return await fn(api);
}
rethrow;
}
}
}
final spotifyProvider = Provider<SpotifyApiWrapper>(
(ref) {
final authState = ref.watch(authenticationProvider);
final anonCred = PrimitiveUtils.getRandomElement(Env.spotifySecrets);
final wrapper = SpotifyApiWrapper(
ref,
authState.asData?.value == null
? SpotifyApi(
SpotifyApiCredentials(
anonCred["clientId"],
anonCred["clientSecret"],
),
)
: SpotifyApi.withAccessToken(
authState.asData!.value!.accessToken.value,
),
);
return wrapper;
},
);

View File

@ -6,5 +6,5 @@ final trackProvider =
final spotify = ref.watch(spotifyProvider);
return spotify.tracks.get(id);
return spotify.invoke((api) => api.tracks.get(id));
});

View File

@ -2,5 +2,5 @@ part of '../spotify.dart';
final meProvider = FutureProvider<User>((ref) async {
final spotify = ref.watch(spotifyProvider);
return spotify.me.get();
return spotify.invoke((api) => api.me.get());
});

View File

@ -2,7 +2,7 @@ part of '../spotify.dart';
// ignore: invalid_use_of_internal_member
mixin SpotifyMixin<T> on AsyncNotifierBase<T> {
SpotifyApi get spotify => ref.read(spotifyProvider);
SpotifyApiWrapper get spotify => ref.read(spotifyProvider);
}
extension on AutoDisposeAsyncNotifierProviderRef {

View File

@ -1,22 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/utils/primitive_utils.dart';
final spotifyProvider = Provider<SpotifyApi>((ref) {
final authState = ref.watch(authenticationProvider);
final anonCred = PrimitiveUtils.getRandomElement(Env.spotifySecrets);
if (authState.asData?.value == null) {
return SpotifyApi(
SpotifyApiCredentials(
anonCred["clientId"],
anonCred["clientSecret"],
),
);
}
return SpotifyApi.withAccessToken(authState.asData!.value!.accessToken.value);
});

View File

@ -42,10 +42,10 @@ packages:
dependency: "direct main"
description:
name: app_links
sha256: ad1a6d598e7e39b46a34f746f9a8b011ee147e4c275d407fa457e7a62f84dd99
sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba"
url: "https://pub.dev"
source: hosted
version: "6.3.2"
version: "6.4.0"
app_links_linux:
dependency: transitive
description:

View File

@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El
publish_to: "none"
version: 4.0.1+40
version: 4.0.2+41
homepage: https://spotube.krtirtho.dev
repository: https://github.com/KRTirtho/spotube
@ -13,7 +13,7 @@ environment:
flutter: ">=3.29.0"
dependencies:
app_links: ^6.3.2
app_links: ^6.4.0
args: ^2.5.0
async: ^2.11.0
audio_service: ^0.18.13