mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-07 15:59:42 +00:00
Compare commits
8 Commits
441dabde8a
...
a3166516ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3166516ae | ||
|
|
ba27dc70e4 | ||
|
|
0ec9f3535b | ||
|
|
df72ba6960 | ||
|
|
d9057dae57 | ||
|
|
e61b79585e | ||
|
|
a9e5636e96 | ||
|
|
f8892c7267 |
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
);
|
||||
|
||||
@ -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
405
lib/l10n/app_zh_TW.arb
Normal 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} 個檔案"
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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() ?? [];
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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() ?? [];
|
||||
|
||||
|
||||
@ -6,5 +6,5 @@ final artistProvider =
|
||||
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
|
||||
return spotify.artists.get(artistId);
|
||||
return spotify.invoke((api) => api.artists.get(artistId));
|
||||
});
|
||||
|
||||
@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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() ?? [];
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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];
|
||||
}
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>();
|
||||
|
||||
|
||||
@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@ -6,5 +6,5 @@ final trackProvider =
|
||||
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
|
||||
return spotify.tracks.get(id);
|
||||
return spotify.invoke((api) => api.tracks.get(id));
|
||||
});
|
||||
|
||||
@ -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());
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user